From f65bfc718d6b3e73325cffbc1ab94a0d1383bcac Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Tue, 17 Sep 2024 17:02:33 -0700 Subject: [PATCH 001/102] [TM-1272] Initial API fetcher and types generation for v3. --- openapi-codegen.config.ts | 25 +++++--- package.json | 3 +- src/generated/v3/apiV3Components.ts | 38 ++++++++++++ src/generated/v3/apiV3Fetcher.ts | 96 +++++++++++++++++++++++++++++ src/generated/v3/apiV3Schemas.ts | 28 +++++++++ 5 files changed, 182 insertions(+), 8 deletions(-) create mode 100644 src/generated/v3/apiV3Components.ts create mode 100644 src/generated/v3/apiV3Fetcher.ts create mode 100644 src/generated/v3/apiV3Schemas.ts diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 56dbd6121..418e0e387 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -1,8 +1,7 @@ import { defineConfig } from "@openapi-codegen/cli"; -import { generateReactQueryComponents, generateSchemaTypes } from "@openapi-codegen/typescript"; +import { generateFetchers, generateReactQueryComponents, generateSchemaTypes } from "@openapi-codegen/typescript"; import dotenv from "dotenv"; dotenv.config(); - export default defineConfig({ api: { from: { @@ -13,26 +12,21 @@ export default defineConfig({ to: async context => { let paths = context.openAPIDocument.paths; let newPaths: any = {}; - //! Treat carefully this might potentially break the api generation // This Logic will make sure every sigle endpoint has a `operationId` key (needed to generate endpoints) Object.keys(paths).forEach((k, i) => { newPaths[k] = {}; const eps = Object.keys(paths[k]).filter(ep => ep !== "parameters"); - eps.forEach((ep, i) => { const current = paths[k][ep]; const operationId = ep + k.replaceAll("/", "-").replaceAll("{", "").replaceAll("}", ""); - newPaths[k][ep] = { ...current, operationId }; }); }); - context.openAPIDocument.paths = newPaths; - const filenamePrefix = "api"; const { schemasFiles } = await generateSchemaTypes(context, { filenamePrefix @@ -42,5 +36,22 @@ export default defineConfig({ schemasFiles }); } + }, + apiV3: { + from: { + source: "url", + url: `${process.env.NEXT_PUBLIC_API_BASE_URL}/user-service/api-json` + }, + outputDir: "src/generated/v3", + to: async context => { + const filenamePrefix = "apiV3"; + const { schemasFiles } = await generateSchemaTypes(context, { + filenamePrefix + }); + await generateFetchers(context, { + filenamePrefix, + schemasFiles + }); + } } }); diff --git a/package.json b/package.json index 5916b735c..38eb6c83d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "postinstall": "patch-package", "dev": "next dev", - "build": "npm run generate:api && next build", + "build": "npm run generate:api && npm run generate:apiv3 && next build", "start": "next start", "test": "jest --watch", "test:coverage": "jest --coverage", @@ -16,6 +16,7 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "generate:api": "npx openapi-codegen gen api", + "generate:apiv3": "npx openapi-codegen gen apiV3", "tx:push": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli push --key-generator=hash src/ --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET", "tx:pull": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli pull --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET" }, diff --git a/src/generated/v3/apiV3Components.ts b/src/generated/v3/apiV3Components.ts new file mode 100644 index 000000000..796dd52e7 --- /dev/null +++ b/src/generated/v3/apiV3Components.ts @@ -0,0 +1,38 @@ +/** + * Generated by @openapi-codegen + * + * @version 1.0 + */ +import type * as Fetcher from "./apiV3Fetcher"; +import { apiV3Fetch } from "./apiV3Fetcher"; +import type * as Schemas from "./apiV3Schemas"; + +export type AuthControllerLoginError = Fetcher.ErrorWrapper<{ + status: 401; + payload: { + /** + * @example 401 + */ + statusCode: number; + /** + * @example Unauthorized + */ + message: string; + }; +}>; + +export type AuthControllerLoginResponse = { + data?: Schemas.LoginResponse; +}; + +export type AuthControllerLoginVariables = { + body: Schemas.LoginRequest; +}; + +export const authControllerLogin = (variables: AuthControllerLoginVariables, signal?: AbortSignal) => + apiV3Fetch({ + url: "/auth/v3/logins", + method: "post", + ...variables, + signal + }); diff --git a/src/generated/v3/apiV3Fetcher.ts b/src/generated/v3/apiV3Fetcher.ts new file mode 100644 index 000000000..8ecb06f38 --- /dev/null +++ b/src/generated/v3/apiV3Fetcher.ts @@ -0,0 +1,96 @@ +export type ApiV3FetcherExtraProps = { + /** + * You can add some extra props to your generated fetchers. + * + * Note: You need to re-gen after adding the first property to + * have the `ApiV3FetcherExtraProps` injected in `ApiV3Components.ts` + **/ +}; + +const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; + +export type ErrorWrapper = TError | { status: "unknown"; payload: string }; + +export type ApiV3FetcherOptions = { + url: string; + method: string; + body?: TBody; + headers?: THeaders; + queryParams?: TQueryParams; + pathParams?: TPathParams; + signal?: AbortSignal; +} & ApiV3FetcherExtraProps; + +export async function apiV3Fetch< + TData, + TError, + TBody extends {} | FormData | undefined | null, + THeaders extends {}, + TQueryParams extends {}, + TPathParams extends {} +>({ + url, + method, + body, + headers, + pathParams, + queryParams, + signal +}: ApiV3FetcherOptions): Promise { + try { + const requestHeaders: HeadersInit = { + "Content-Type": "application/json", + ...headers + }; + + /** + * As the fetch API is being used, when multipart/form-data is specified + * the Content-Type header must be deleted so that the browser can set + * the correct boundary. + * https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object + */ + if (requestHeaders["Content-Type"].toLowerCase().includes("multipart/form-data")) { + delete requestHeaders["Content-Type"]; + } + + const response = await window.fetch(`${baseUrl}${resolveUrl(url, queryParams, pathParams)}`, { + signal, + method: method.toUpperCase(), + body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined, + headers: requestHeaders + }); + if (!response.ok) { + let error: ErrorWrapper; + try { + error = await response.json(); + } catch (e) { + error = { + status: "unknown" as const, + payload: e instanceof Error ? `Unexpected error (${e.message})` : "Unexpected error" + }; + } + + throw error; + } + + if (response.headers.get("content-type")?.includes("json")) { + return await response.json(); + } else { + // if it is not a json response, assume it is a blob and cast it to TData + return (await response.blob()) as unknown as TData; + } + } catch (e) { + let errorObject: Error = { + name: "unknown" as const, + message: e instanceof Error ? `Network error (${e.message})` : "Network error", + stack: e as string + }; + throw errorObject; + } +} + +const resolveUrl = (url: string, queryParams: Record = {}, pathParams: Record = {}) => { + let query = new URLSearchParams(queryParams).toString(); + if (query) query = `?${query}`; + return url.replace(/\{\w*\}/g, key => pathParams[key.slice(1, -1)]) + query; +}; diff --git a/src/generated/v3/apiV3Schemas.ts b/src/generated/v3/apiV3Schemas.ts new file mode 100644 index 000000000..c95b87215 --- /dev/null +++ b/src/generated/v3/apiV3Schemas.ts @@ -0,0 +1,28 @@ +/** + * Generated by @openapi-codegen + * + * @version 1.0 + */ +export type LoginResponse = { + /** + * @example logins + */ + type: string; + /** + * The ID of the user associated with this login + * + * @example 1234 + */ + id: string; + /** + * JWT token for use in future authenticated requests to the API. + * + * @example + */ + token: string; +}; + +export type LoginRequest = { + emailAddress: string; + password: string; +}; From 3d87fe6ea9fe1becf7e26ca2930edf7cde5654d3 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Tue, 17 Sep 2024 17:05:49 -0700 Subject: [PATCH 002/102] [TM-1272] Namespace the generated client side files by service. --- openapi-codegen.config.ts | 6 +++--- package.json | 4 ++-- .../userServiceComponents.ts} | 8 ++++---- .../userServiceFetcher.ts} | 12 ++++++------ .../userServiceSchemas.ts} | 0 5 files changed, 15 insertions(+), 15 deletions(-) rename src/generated/v3/{apiV3Components.ts => userService/userServiceComponents.ts} (69%) rename src/generated/v3/{apiV3Fetcher.ts => userService/userServiceFetcher.ts} (87%) rename src/generated/v3/{apiV3Schemas.ts => userService/userServiceSchemas.ts} (100%) diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 418e0e387..3c444a153 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -37,14 +37,14 @@ export default defineConfig({ }); } }, - apiV3: { + userService: { from: { source: "url", url: `${process.env.NEXT_PUBLIC_API_BASE_URL}/user-service/api-json` }, - outputDir: "src/generated/v3", + outputDir: "src/generated/v3/userService", to: async context => { - const filenamePrefix = "apiV3"; + const filenamePrefix = "userService"; const { schemasFiles } = await generateSchemaTypes(context, { filenamePrefix }); diff --git a/package.json b/package.json index 38eb6c83d..a23b41a7b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "postinstall": "patch-package", "dev": "next dev", - "build": "npm run generate:api && npm run generate:apiv3 && next build", + "build": "npm run generate:api && npm run generate:userService && next build", "start": "next start", "test": "jest --watch", "test:coverage": "jest --coverage", @@ -16,7 +16,7 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "generate:api": "npx openapi-codegen gen api", - "generate:apiv3": "npx openapi-codegen gen apiV3", + "generate:userService": "npx openapi-codegen gen userService", "tx:push": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli push --key-generator=hash src/ --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET", "tx:pull": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli pull --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET" }, diff --git a/src/generated/v3/apiV3Components.ts b/src/generated/v3/userService/userServiceComponents.ts similarity index 69% rename from src/generated/v3/apiV3Components.ts rename to src/generated/v3/userService/userServiceComponents.ts index 796dd52e7..a899d7028 100644 --- a/src/generated/v3/apiV3Components.ts +++ b/src/generated/v3/userService/userServiceComponents.ts @@ -3,9 +3,9 @@ * * @version 1.0 */ -import type * as Fetcher from "./apiV3Fetcher"; -import { apiV3Fetch } from "./apiV3Fetcher"; -import type * as Schemas from "./apiV3Schemas"; +import type * as Fetcher from "./userServiceFetcher"; +import { userServiceFetch } from "./userServiceFetcher"; +import type * as Schemas from "./userServiceSchemas"; export type AuthControllerLoginError = Fetcher.ErrorWrapper<{ status: 401; @@ -30,7 +30,7 @@ export type AuthControllerLoginVariables = { }; export const authControllerLogin = (variables: AuthControllerLoginVariables, signal?: AbortSignal) => - apiV3Fetch({ + userServiceFetch({ url: "/auth/v3/logins", method: "post", ...variables, diff --git a/src/generated/v3/apiV3Fetcher.ts b/src/generated/v3/userService/userServiceFetcher.ts similarity index 87% rename from src/generated/v3/apiV3Fetcher.ts rename to src/generated/v3/userService/userServiceFetcher.ts index 8ecb06f38..8822011b8 100644 --- a/src/generated/v3/apiV3Fetcher.ts +++ b/src/generated/v3/userService/userServiceFetcher.ts @@ -1,9 +1,9 @@ -export type ApiV3FetcherExtraProps = { +export type UserServiceFetcherExtraProps = { /** * You can add some extra props to your generated fetchers. * * Note: You need to re-gen after adding the first property to - * have the `ApiV3FetcherExtraProps` injected in `ApiV3Components.ts` + * have the `UserServiceFetcherExtraProps` injected in `UserServiceComponents.ts` **/ }; @@ -11,7 +11,7 @@ const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; export type ErrorWrapper = TError | { status: "unknown"; payload: string }; -export type ApiV3FetcherOptions = { +export type UserServiceFetcherOptions = { url: string; method: string; body?: TBody; @@ -19,9 +19,9 @@ export type ApiV3FetcherOptions = { queryParams?: TQueryParams; pathParams?: TPathParams; signal?: AbortSignal; -} & ApiV3FetcherExtraProps; +} & UserServiceFetcherExtraProps; -export async function apiV3Fetch< +export async function userServiceFetch< TData, TError, TBody extends {} | FormData | undefined | null, @@ -36,7 +36,7 @@ export async function apiV3Fetch< pathParams, queryParams, signal -}: ApiV3FetcherOptions): Promise { +}: UserServiceFetcherOptions): Promise { try { const requestHeaders: HeadersInit = { "Content-Type": "application/json", diff --git a/src/generated/v3/apiV3Schemas.ts b/src/generated/v3/userService/userServiceSchemas.ts similarity index 100% rename from src/generated/v3/apiV3Schemas.ts rename to src/generated/v3/userService/userServiceSchemas.ts From ec064de473466221f4f3d92e9402d3e50bbd6d84 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 18 Sep 2024 14:09:53 -0700 Subject: [PATCH 003/102] [TM-1272] Update the service documentation path. --- openapi-codegen.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 3c444a153..44b874e20 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -40,7 +40,7 @@ export default defineConfig({ userService: { from: { source: "url", - url: `${process.env.NEXT_PUBLIC_API_BASE_URL}/user-service/api-json` + url: `${process.env.NEXT_PUBLIC_API_BASE_URL}/user-service/documentation/api-json` }, outputDir: "src/generated/v3/userService", to: async context => { From 0a469ef106c31904f911904d2714109cbcd069dd Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 19 Sep 2024 10:24:10 -0700 Subject: [PATCH 004/102] [TM-1272] API Connection system implemented. --- openapi-codegen.config.ts | 380 +++++++++++++++++- package.json | 14 +- src/connections/Login.ts | 47 +++ .../v3/userService/userServiceComponents.ts | 13 +- .../v3/userService/userServiceFetcher.ts | 89 ++-- .../v3/userService/userServicePredicates.ts | 8 + src/generated/v3/utils.ts | 93 +++++ src/hooks/useConnection.ts | 52 +++ src/hooks/usePrevious.ts | 14 + src/pages/_app.tsx | 10 +- src/pages/api/auth/login.tsx | 17 - src/pages/api/auth/logout.tsx | 9 - src/pages/auth/login/components/LoginForm.tsx | 2 +- src/pages/auth/login/index.page.tsx | 35 +- src/store/apiSlice.ts | 90 +++++ src/store/store.ts | 11 + src/types/connection.ts | 16 + yarn.lock | 78 +++- 18 files changed, 838 insertions(+), 140 deletions(-) create mode 100644 src/connections/Login.ts create mode 100644 src/generated/v3/userService/userServicePredicates.ts create mode 100644 src/generated/v3/utils.ts create mode 100644 src/hooks/useConnection.ts create mode 100644 src/hooks/usePrevious.ts delete mode 100644 src/pages/api/auth/login.tsx delete mode 100644 src/pages/api/auth/logout.tsx create mode 100644 src/store/apiSlice.ts create mode 100644 src/store/store.ts create mode 100644 src/types/connection.ts diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 44b874e20..78ed5ce64 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -1,8 +1,32 @@ +/* eslint-disable no-case-declarations */ import { defineConfig } from "@openapi-codegen/cli"; +import { Config } from "@openapi-codegen/cli/lib/types"; import { generateFetchers, generateReactQueryComponents, generateSchemaTypes } from "@openapi-codegen/typescript"; +import { ConfigBase, Context } from "@openapi-codegen/typescript/lib/generators/types"; +import c from "case"; import dotenv from "dotenv"; +import _ from "lodash"; +import { + ComponentsObject, + isReferenceObject, + OpenAPIObject, + OperationObject, + ParameterObject, + PathItemObject +} from "openapi3-ts"; +import ts from "typescript"; + +const f = ts.factory; + dotenv.config(); -export default defineConfig({ + +// The services defined in the v3 Node BE codebase. Although the URL path for APIs in the v3 space +// are namespaced by feature set rather than service (a service may contain multiple namespaces), we +// isolate the generated API integration by service to make it easier for a developer to find where +// the associated BE code is for a given FE API integration. +const SERVICES = ["user-service"]; + +const config: Record = { api: { from: { source: "url", @@ -13,11 +37,11 @@ export default defineConfig({ let paths = context.openAPIDocument.paths; let newPaths: any = {}; //! Treat carefully this might potentially break the api generation - // This Logic will make sure every sigle endpoint has a `operationId` key (needed to generate endpoints) - Object.keys(paths).forEach((k, i) => { + // This Logic will make sure every single endpoint has a `operationId` key (needed to generate endpoints) + Object.keys(paths).forEach(k => { newPaths[k] = {}; const eps = Object.keys(paths[k]).filter(ep => ep !== "parameters"); - eps.forEach((ep, i) => { + eps.forEach(ep => { const current = paths[k][ep]; const operationId = ep + k.replaceAll("/", "-").replaceAll("{", "").replaceAll("}", ""); newPaths[k][ep] = { @@ -36,22 +60,344 @@ export default defineConfig({ schemasFiles }); } - }, - userService: { + } +}; + +for (const service of SERVICES) { + const name = _.camelCase(service); + config[name] = { from: { source: "url", - url: `${process.env.NEXT_PUBLIC_API_BASE_URL}/user-service/documentation/api-json` + url: `${process.env.NEXT_PUBLIC_API_BASE_URL}/${service}/documentation/api-json` }, - outputDir: "src/generated/v3/userService", + outputDir: `src/generated/v3/${name}`, to: async context => { - const filenamePrefix = "userService"; - const { schemasFiles } = await generateSchemaTypes(context, { - filenamePrefix - }); - await generateFetchers(context, { - filenamePrefix, - schemasFiles - }); + const { schemasFiles } = await generateSchemaTypes(context, { filenamePrefix: name }); + await generateFetchers(context, { filenamePrefix: name, schemasFiles }); + await generatePendingPredicates(context, { filenamePrefix: name }); } + }; +} + +export default defineConfig(config); + +/** + * Generates Connection predicates for checking if a given request is in progress or failed. + * + * Based on generators from https://github.com/fabien0102/openapi-codegen/blob/main/plugins/typescript. Many of the + * methods here are similar to ones in that repo, but they aren't exported, so were copied from there and modified for + * use in this generator. + */ +const generatePendingPredicates = async (context: Context, config: ConfigBase) => { + const sourceFile = ts.createSourceFile("index.ts", "", ts.ScriptTarget.Latest); + + const printer = ts.createPrinter({ + newLine: ts.NewLineKind.LineFeed, + removeComments: false + }); + + const printNodes = (nodes: ts.Node[]) => + nodes + .map((node: ts.Node, i, nodes) => { + return ( + printer.printNode(ts.EmitHint.Unspecified, node, sourceFile) + + (ts.isJSDoc(node) || (ts.isImportDeclaration(node) && nodes[i + 1] && ts.isImportDeclaration(nodes[i + 1])) + ? "" + : "\n") + ); + }) + .join("\n"); + + const filenamePrefix = c.snake(config.filenamePrefix ?? context.openAPIDocument.info.title) + "-"; + const formatFilename = config.filenameCase ? c[config.filenameCase] : c.camel; + const filename = formatFilename(filenamePrefix + "-predicates"); + const nodes: ts.Node[] = []; + const componentImports: string[] = []; + + let variablesExtraPropsType: ts.TypeNode = f.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword); + + Object.entries(context.openAPIDocument.paths).forEach(([route, verbs]: [string, PathItemObject]) => { + Object.entries(verbs).forEach(([verb, operation]) => { + if (!isVerb(verb) || !isOperationObject(operation)) return; + + const operationId = c.camel(operation.operationId); + const { pathParamsType, variablesType, queryParamsType } = getOperationTypes({ + openAPIDocument: context.openAPIDocument, + operation, + operationId, + pathParameters: verbs.parameters, + variablesExtraPropsType + }); + + for (const type of [pathParamsType, queryParamsType, variablesType]) { + if (ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName)) { + componentImports.push(type.typeName.text); + } + } + + nodes.push( + ...createPredicateNodes({ + pathParamsType, + variablesType, + queryParamsType, + url: route, + verb, + name: operationId + }) + ); + }); + }); + + await context.writeFile( + filename + ".ts", + printNodes([ + createNamedImport(["ApiDataStore"], "@/types/connection"), + createNamedImport(["isFetching", "apiFetchFailed"], `../utils`), + ...(componentImports.length == 0 + ? [] + : [createNamedImport(componentImports, `./${formatFilename(filenamePrefix + "-components")}`)]), + ...nodes + ]) + ); +}; + +const camelizedPathParams = (url: string) => url.replace(/\{\w*}/g, match => `{${c.camel(match)}}`); + +const createPredicateNodes = ({ + queryParamsType, + pathParamsType, + variablesType, + url, + verb, + name +}: { + pathParamsType: ts.TypeNode; + queryParamsType: ts.TypeNode; + variablesType: ts.TypeNode; + url: string; + verb: string; + name: string; +}) => { + const nodes: ts.Node[] = []; + + const stateTypeDeclaration = f.createParameterDeclaration( + undefined, + undefined, + f.createIdentifier("state"), + undefined, + f.createTypeReferenceNode("ApiDataStore"), + undefined + ); + + nodes.push( + ...["isFetching", "apiFetchFailed"].map(fnName => + f.createVariableStatement( + [f.createModifier(ts.SyntaxKind.ExportKeyword)], + f.createVariableDeclarationList( + [ + f.createVariableDeclaration( + f.createIdentifier(`${name}${_.upperFirst(fnName)}`), + undefined, + undefined, + f.createArrowFunction( + undefined, + undefined, + variablesType.kind !== ts.SyntaxKind.VoidKeyword + ? [ + stateTypeDeclaration, + f.createParameterDeclaration( + undefined, + undefined, + f.createIdentifier("variables"), + undefined, + variablesType, + undefined + ) + ] + : [stateTypeDeclaration], + undefined, + f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + f.createCallExpression( + f.createIdentifier(fnName), + [queryParamsType, pathParamsType], + [ + f.createObjectLiteralExpression( + [ + f.createShorthandPropertyAssignment("state"), + f.createPropertyAssignment( + f.createIdentifier("url"), + f.createStringLiteral(camelizedPathParams(url)) + ), + f.createPropertyAssignment(f.createIdentifier("method"), f.createStringLiteral(verb)), + ...(variablesType.kind !== ts.SyntaxKind.VoidKeyword + ? [f.createSpreadAssignment(f.createIdentifier("variables"))] + : []) + ], + false + ) + ] + ) + ) + ) + ], + ts.NodeFlags.Const + ) + ) + ) + ); + + return nodes; +}; + +const isVerb = (verb: string): verb is "get" | "post" | "patch" | "put" | "delete" => + ["get", "post", "patch", "put", "delete"].includes(verb); + +const isOperationObject = (obj: any): obj is OperationObject & { operationId: string } => + typeof obj === "object" && typeof (obj as any).operationId === "string"; + +export type GetOperationTypesOptions = { + operationId: string; + operation: OperationObject; + openAPIDocument: OpenAPIObject; + pathParameters?: PathItemObject["parameters"]; + variablesExtraPropsType: ts.TypeNode; +}; + +export type GetOperationTypesOutput = { + pathParamsType: ts.TypeNode; + variablesType: ts.TypeNode; + queryParamsType: ts.TypeNode; +}; + +const getParamsGroupByType = (parameters: OperationObject["parameters"] = [], components: ComponentsObject = {}) => { + const { query: queryParams = [] as ParameterObject[], path: pathParams = [] as ParameterObject[] } = _.groupBy( + [...parameters].map(p => { + if (isReferenceObject(p)) { + const schema = _.get(components, p.$ref.replace("#/components/", "").replace("/", ".")); + if (!schema) { + throw new Error(`${p.$ref} not found!`); + } + return schema; + } else { + return p; + } + }), + "in" + ); + + return { queryParams, pathParams }; +}; + +export const getVariablesType = ({ + pathParamsType, + pathParamsOptional, + queryParamsType, + queryParamsOptional +}: { + pathParamsType: ts.TypeNode; + pathParamsOptional: boolean; + queryParamsType: ts.TypeNode; + queryParamsOptional: boolean; +}) => { + const variablesItems: ts.TypeElement[] = []; + + const hasProperties = (node: ts.Node) => { + return (!ts.isTypeLiteralNode(node) || node.members.length > 0) && node.kind !== ts.SyntaxKind.UndefinedKeyword; + }; + + if (hasProperties(pathParamsType)) { + variablesItems.push( + f.createPropertySignature( + undefined, + f.createIdentifier("pathParams"), + pathParamsOptional ? f.createToken(ts.SyntaxKind.QuestionToken) : undefined, + pathParamsType + ) + ); + } + if (hasProperties(queryParamsType)) { + variablesItems.push( + f.createPropertySignature( + undefined, + f.createIdentifier("queryParams"), + queryParamsOptional ? f.createToken(ts.SyntaxKind.QuestionToken) : undefined, + queryParamsType + ) + ); + } + + return variablesItems.length === 0 + ? f.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword) + : f.createTypeLiteralNode(variablesItems); +}; + +const getOperationTypes = ({ + operationId, + operation, + openAPIDocument, + pathParameters = [], + variablesExtraPropsType +}: GetOperationTypesOptions): GetOperationTypesOutput => { + // Generate params types + const { pathParams, queryParams } = getParamsGroupByType( + [...pathParameters, ...(operation.parameters || [])], + openAPIDocument.components + ); + + const pathParamsOptional = pathParams.reduce((mem, p) => { + return mem && !p.required; + }, true); + const queryParamsOptional = queryParams.reduce((mem, p) => { + return mem && !p.required; + }, true); + + const pathParamsType = + pathParams.length > 0 + ? f.createTypeReferenceNode(`${c.pascal(operationId)}PathParams`) + : f.createTypeLiteralNode([]); + + const queryParamsType = + queryParams.length > 0 + ? f.createTypeReferenceNode(`${c.pascal(operationId)}QueryParams`) + : f.createTypeLiteralNode([]); + + const variablesIdentifier = c.pascal(`${operationId}Variables`); + + let variablesType: ts.TypeNode = getVariablesType({ + pathParamsType, + queryParamsType, + pathParamsOptional, + queryParamsOptional + }); + + if (variablesExtraPropsType.kind !== ts.SyntaxKind.VoidKeyword) { + variablesType = + variablesType.kind === ts.SyntaxKind.VoidKeyword + ? variablesExtraPropsType + : f.createIntersectionTypeNode([variablesType, variablesExtraPropsType]); + } + + if (variablesType.kind !== ts.SyntaxKind.VoidKeyword) { + variablesType = f.createTypeReferenceNode(variablesIdentifier); } -}); + + return { + pathParamsType, + queryParamsType, + variablesType + }; +}; + +const createNamedImport = (fnName: string | string[], filename: string, isTypeOnly = false) => { + const fnNames = Array.isArray(fnName) ? fnName : [fnName]; + return f.createImportDeclaration( + undefined, + f.createImportClause( + isTypeOnly, + undefined, + f.createNamedImports(fnNames.map(name => f.createImportSpecifier(false, undefined, f.createIdentifier(name)))) + ), + f.createStringLiteral(filename), + undefined + ); +}; diff --git a/package.json b/package.json index a23b41a7b..c9539a9d7 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "postinstall": "patch-package", "dev": "next dev", - "build": "npm run generate:api && npm run generate:userService && next build", + "build": "npm run generate:api && npm run generate:services && next build", "start": "next start", "test": "jest --watch", "test:coverage": "jest --coverage", @@ -15,8 +15,9 @@ "prepare": "husky install", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "generate:api": "npx openapi-codegen gen api", - "generate:userService": "npx openapi-codegen gen userService", + "generate:api": "openapi-codegen gen api", + "generate:userService": "openapi-codegen gen userService", + "generate:services": "npm run generate:userService", "tx:push": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli push --key-generator=hash src/ --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET", "tx:pull": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli pull --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET" }, @@ -31,6 +32,7 @@ "@mui/icons-material": "^5.11.0", "@mui/material": "^5.11.7", "@mui/x-data-grid": "^6.16.1", + "@reduxjs/toolkit": "^2.2.7", "@sentry/nextjs": "^7.109.0", "@tailwindcss/forms": "^0.5.3", "@tanstack/react-query": "^4.23.0", @@ -42,6 +44,7 @@ "@transifex/react": "^5.0.6", "@turf/bbox": "^6.5.0", "canvg": "^4.0.1", + "case": "^1.6.3", "circle-to-polygon": "^2.2.0", "classnames": "^2.3.2", "date-fns": "^2.29.3", @@ -69,6 +72,9 @@ "react-if": "^4.1.4", "react-inlinesvg": "^3.0.0", "react-joyride": "^2.5.5", + "react-redux": "^9.1.2", + "redux-logger": "^3.0.6", + "reselect": "^4.1.8", "swiper": "^9.0.5", "tailwind-merge": "^1.14.0", "typescript": "4.9.4", @@ -100,9 +106,11 @@ "@types/mapbox-gl": "^2.7.13", "@types/mapbox__mapbox-gl-draw": "^1.4.1", "@types/node": "18.11.18", + "@types/pluralize": "^0.0.33", "@types/react": "18.0.27", "@types/react-dom": "18.0.10", "@types/react-test-renderer": "^18.0.0", + "@types/redux-logger": "^3.0.13", "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^5.10.2", "@typescript-eslint/parser": "^5.10.2", diff --git a/src/connections/Login.ts b/src/connections/Login.ts new file mode 100644 index 000000000..5b551150d --- /dev/null +++ b/src/connections/Login.ts @@ -0,0 +1,47 @@ +import { createSelector } from "reselect"; + +import { authLogin } from "@/generated/v3/userService/userServiceComponents"; +import { authLoginFetchFailed, authLoginIsFetching } from "@/generated/v3/userService/userServicePredicates"; +import { Connection } from "@/types/connection"; + +type LoginConnection = { + isLoggingIn: boolean; + isLoggedIn: boolean; + loginFailed: boolean; + login: (emailAddress: string, password: string) => void; +}; + +const login = (emailAddress: string, password: string) => authLogin({ body: { emailAddress, password } }); + +export const loginConnection: Connection = { + selector: createSelector([authLoginIsFetching, authLoginFetchFailed], (isLoggingIn, failedLogin) => { + return { + isLoggingIn, + // TODO get from auth token + isLoggedIn: false, + loginFailed: failedLogin != null, + + login + }; + }) + + // selector(state: ApiDataStore): LoginConnection { + // const values = Object.values(state.logins); + // if (values.length > 1) { + // console.error("More than one Login recorded in the store!", state.logins); + // } + // + // // TODO We don't actually want the token to be part of the shape in this case, or to come from + // // the store. The token should always be fetched from local storage so that logins persist. + // const authToken = values[0]?.token; + // return { + // authToken, + // isLoggingIn: authLoginIsFetching(state), + // isLoggedIn: authToken != null, + // loginFailed: authLoginFetchFailed(state) != null, + // login: (emailAddress: string, password: string) => { + // authLogin({ body: { emailAddress, password } }); + // } + // }; + // } +}; diff --git a/src/generated/v3/userService/userServiceComponents.ts b/src/generated/v3/userService/userServiceComponents.ts index a899d7028..cd96b66da 100644 --- a/src/generated/v3/userService/userServiceComponents.ts +++ b/src/generated/v3/userService/userServiceComponents.ts @@ -7,7 +7,7 @@ import type * as Fetcher from "./userServiceFetcher"; import { userServiceFetch } from "./userServiceFetcher"; import type * as Schemas from "./userServiceSchemas"; -export type AuthControllerLoginError = Fetcher.ErrorWrapper<{ +export type AuthLoginError = Fetcher.ErrorWrapper<{ status: 401; payload: { /** @@ -21,16 +21,19 @@ export type AuthControllerLoginError = Fetcher.ErrorWrapper<{ }; }>; -export type AuthControllerLoginResponse = { +export type AuthLoginResponse = { data?: Schemas.LoginResponse; }; -export type AuthControllerLoginVariables = { +export type AuthLoginVariables = { body: Schemas.LoginRequest; }; -export const authControllerLogin = (variables: AuthControllerLoginVariables, signal?: AbortSignal) => - userServiceFetch({ +/** + * Receive a JWT Token in exchange for login credentials + */ +export const authLogin = (variables: AuthLoginVariables, signal?: AbortSignal) => + userServiceFetch({ url: "/auth/v3/logins", method: "post", ...variables, diff --git a/src/generated/v3/userService/userServiceFetcher.ts b/src/generated/v3/userService/userServiceFetcher.ts index 8822011b8..46bfad6e4 100644 --- a/src/generated/v3/userService/userServiceFetcher.ts +++ b/src/generated/v3/userService/userServiceFetcher.ts @@ -1,3 +1,9 @@ +import { dispatchRequest, resolveUrl } from "@/generated/v3/utils"; + +// This type is imported in the auto generated `userServiceComponents` file, so it needs to be +// exported from this file. +export type { ErrorWrapper } from "../utils"; + export type UserServiceFetcherExtraProps = { /** * You can add some extra props to your generated fetchers. @@ -7,10 +13,6 @@ export type UserServiceFetcherExtraProps = { **/ }; -const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; - -export type ErrorWrapper = TError | { status: "unknown"; payload: string }; - export type UserServiceFetcherOptions = { url: string; method: string; @@ -21,7 +23,7 @@ export type UserServiceFetcherOptions): Promise { - try { - const requestHeaders: HeadersInit = { - "Content-Type": "application/json", - ...headers - }; - - /** - * As the fetch API is being used, when multipart/form-data is specified - * the Content-Type header must be deleted so that the browser can set - * the correct boundary. - * https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object - */ - if (requestHeaders["Content-Type"].toLowerCase().includes("multipart/form-data")) { - delete requestHeaders["Content-Type"]; - } - - const response = await window.fetch(`${baseUrl}${resolveUrl(url, queryParams, pathParams)}`, { - signal, - method: method.toUpperCase(), - body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined, - headers: requestHeaders - }); - if (!response.ok) { - let error: ErrorWrapper; - try { - error = await response.json(); - } catch (e) { - error = { - status: "unknown" as const, - payload: e instanceof Error ? `Unexpected error (${e.message})` : "Unexpected error" - }; - } +}: UserServiceFetcherOptions) { + const requestHeaders: HeadersInit = { + "Content-Type": "application/json", + ...headers + }; - throw error; - } - - if (response.headers.get("content-type")?.includes("json")) { - return await response.json(); - } else { - // if it is not a json response, assume it is a blob and cast it to TData - return (await response.blob()) as unknown as TData; - } - } catch (e) { - let errorObject: Error = { - name: "unknown" as const, - message: e instanceof Error ? `Network error (${e.message})` : "Network error", - stack: e as string - }; - throw errorObject; + /** + * As the fetch API is being used, when multipart/form-data is specified + * the Content-Type header must be deleted so that the browser can set + * the correct boundary. + * https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object + */ + if (requestHeaders["Content-Type"].toLowerCase().includes("multipart/form-data")) { + delete requestHeaders["Content-Type"]; } -} -const resolveUrl = (url: string, queryParams: Record = {}, pathParams: Record = {}) => { - let query = new URLSearchParams(queryParams).toString(); - if (query) query = `?${query}`; - return url.replace(/\{\w*\}/g, key => pathParams[key.slice(1, -1)]) + query; -}; + // The promise is ignored on purpose. Further progress of the request is tracked through + // redux. + dispatchRequest(resolveUrl(url, queryParams, pathParams), { + signal, + method: method.toUpperCase(), + body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined, + headers: requestHeaders + }); +} diff --git a/src/generated/v3/userService/userServicePredicates.ts b/src/generated/v3/userService/userServicePredicates.ts new file mode 100644 index 000000000..a79ea180b --- /dev/null +++ b/src/generated/v3/userService/userServicePredicates.ts @@ -0,0 +1,8 @@ +import { isFetching, fetchFailed } from "../utils"; +import { ApiDataStore } from "@/store/apiSlice"; + +export const authLoginIsFetching = (state: ApiDataStore) => + isFetching<{}, {}>({ state, url: "/auth/v3/logins", method: "post" }); + +export const authLoginFetchFailed = (state: ApiDataStore) => + fetchFailed<{}, {}>({ state, url: "/auth/v3/logins", method: "post" }); diff --git a/src/generated/v3/utils.ts b/src/generated/v3/utils.ts new file mode 100644 index 000000000..20274c12f --- /dev/null +++ b/src/generated/v3/utils.ts @@ -0,0 +1,93 @@ +import { + ApiDataStore, + apiFetchFailed, + apiFetchStarting, + apiFetchSucceeded, + isErrorState, + isInProgress, + Method, + PendingErrorState +} from "@/store/apiSlice"; +import store from "@/store/store"; + +const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; + +export type ErrorWrapper = TError | { statusCode: -1; message: string }; + +type SelectorOptions = { + state: ApiDataStore; + url: string; + method: string; + queryParams?: TQueryParams; + pathParams?: TPathParams; +}; + +export const resolveUrl = ( + url: string, + queryParams: Record = {}, + pathParams: Record = {} +) => { + const searchParams = new URLSearchParams(queryParams); + // Make sure the output string always ends up in the same order because we need the URL string + // that is generated from a set of query / path params to be consistent even if the order of the + // params in the source object changes. + searchParams.sort(); + let query = searchParams.toString(); + if (query) query = `?${query}`; + return `${baseUrl}${url.replace(/\{\w*}/g, key => pathParams[key.slice(1, -1)]) + query}`; +}; + +export function isFetching({ + state, + url, + method, + pathParams, + queryParams +}: SelectorOptions): boolean { + const fullUrl = resolveUrl(url, queryParams, pathParams); + const pending = state.meta.pending[method.toUpperCase() as Method][fullUrl]; + return isInProgress(pending); +} + +export function fetchFailed({ + state, + url, + method, + pathParams, + queryParams +}: SelectorOptions): PendingErrorState | null { + const fullUrl = resolveUrl(url, queryParams, pathParams); + const pending = state.meta.pending[method.toUpperCase() as Method][fullUrl]; + return isErrorState(pending) ? pending : null; +} + +export async function dispatchRequest(url: string, requestInit: RequestInit) { + const actionPayload = { url, method: requestInit.method as Method }; + store.dispatch(apiFetchStarting(actionPayload)); + + try { + const response = await window.fetch(url, requestInit); + + if (!response.ok) { + const error = (await response.json()) as ErrorWrapper; + store.dispatch(apiFetchFailed({ ...actionPayload, error: error as PendingErrorState })); + return; + } + + if (!response.headers.get("content-type")?.includes("json")) { + // this API integration only supports JSON type responses at the moment. + throw new Error(`Response type is not JSON [${response.headers.get("content-type")}]`); + } + + const responsePayload = await response.json(); + if (responsePayload.statusCode != null && responsePayload.message != null) { + store.dispatch(apiFetchFailed({ ...actionPayload, error: responsePayload })); + } else { + store.dispatch(apiFetchSucceeded({ ...actionPayload, response: responsePayload })); + } + } catch (e) { + console.error("Unexpected API fetch failure", e); + const message = e instanceof Error ? `Network error (${e.message})` : "Network error"; + store.dispatch(apiFetchFailed({ ...actionPayload, error: { statusCode: -1, message } })); + } +} diff --git a/src/hooks/useConnection.ts b/src/hooks/useConnection.ts new file mode 100644 index 000000000..926fc0a8d --- /dev/null +++ b/src/hooks/useConnection.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from "react"; + +import store from "@/store/store"; +import { Connected, Connection, OptionalProps } from "@/types/connection"; + +/** + * Use a connection to efficiently depend on data in the Redux store. + * + * In this hook, an internal subscription to the store is used instead of a useSelector() on the + * whole API state. This limits redraws of the component to the times that the connected state of + * the Connection changes. + */ +export function useConnection( + connection: Connection, + props: TProps | Record = {} +): Connected { + const { selector, isLoaded, load } = connection; + + const getConnected = useCallback(() => { + const connected = selector(store.getState().api, props); + const loadingDone = isLoaded == null || isLoaded(connected, props); + return { loadingDone, connected }; + }, [isLoaded, props, selector]); + + const [connected, setConnected] = useState(() => { + const { loadingDone, connected } = getConnected(); + return loadingDone ? connected : null; + }); + + useEffect( + () => { + function checkState() { + const { loadingDone, connected: currentConnected } = getConnected(); + if (load != null) load(currentConnected, props); + if (loadingDone) { + setConnected(currentConnected); + } else { + // In case something was loaded and then got unloaded via a redux store clear + if (connected != null) setConnected(null); + } + } + + const subscription = store.subscribe(checkState); + checkState(); + return subscription; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [connection, ...Object.keys(props ?? [])] + ); + + return [connected != null, connected ?? {}]; +} diff --git a/src/hooks/usePrevious.ts b/src/hooks/usePrevious.ts new file mode 100644 index 000000000..7dd8a1021 --- /dev/null +++ b/src/hooks/usePrevious.ts @@ -0,0 +1,14 @@ +import { useEffect, useRef } from "react"; + +export function usePrevious(value: T): T | undefined { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }, [value]); + return ref.current; +} + +export function useValueChanged(value: T, callback: () => void) { + const previous = usePrevious(value); + if (previous !== value) callback(); +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 88a6d7b7e..c63cf3a5b 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -8,6 +8,7 @@ import App from "next/app"; import dynamic from "next/dynamic"; import { useRouter } from "next/router"; import nookies from "nookies"; +import { Provider as ReduxProvider } from "react-redux"; import Toast from "@/components/elements/Toast/Toast"; import ModalRoot from "@/components/extensive/Modal/ModalRoot"; @@ -21,6 +22,7 @@ import WrappedQueryClientProvider from "@/context/queryclient.provider"; import RouteHistoryProvider from "@/context/routeHistory.provider"; import ToastProvider from "@/context/toast.provider"; import { getServerSideTranslations, setClientSideTranslations } from "@/i18n"; +import store from "@/store/store"; import setupYup from "@/yup.locale"; const CookieBanner = dynamic(() => import("@/components/extensive/CookieBanner/CookieBanner"), { @@ -37,7 +39,7 @@ const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessT if (isAdmin) return ( - <> + @@ -50,11 +52,11 @@ const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessT - + ); else return ( - <> + @@ -80,7 +82,7 @@ const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessT - + ); }; diff --git a/src/pages/api/auth/login.tsx b/src/pages/api/auth/login.tsx deleted file mode 100644 index fc2f50a5e..000000000 --- a/src/pages/api/auth/login.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { NextApiRequest, NextApiResponse } from "next"; -import { setCookie } from "nookies"; - -export default function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== "POST") return res.send("only POST"); - - const token = req.body.token; - - setCookie({ res }, "accessToken", token, { - maxAge: 60 * 60 * 12, // 12 hours - // httpOnly: true, - secure: process.env.NODE_ENV !== "development", - path: "/" - }); - - res.status(200).json({ success: true }); -} diff --git a/src/pages/api/auth/logout.tsx b/src/pages/api/auth/logout.tsx deleted file mode 100644 index 3ccb00eda..000000000 --- a/src/pages/api/auth/logout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { NextApiRequest, NextApiResponse } from "next"; - -export default function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== "POST") return res.send("only POST"); - - res.setHeader("Set-Cookie", "accessToken=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"); - - res.status(200).json({ success: true }); -} diff --git a/src/pages/auth/login/components/LoginForm.tsx b/src/pages/auth/login/components/LoginForm.tsx index 55d8eb74b..1aaa74d65 100644 --- a/src/pages/auth/login/components/LoginForm.tsx +++ b/src/pages/auth/login/components/LoginForm.tsx @@ -11,7 +11,7 @@ import { LoginFormDataType } from "../index.page"; type LoginFormProps = { form: UseFormReturn; - handleSave: (data: LoginFormDataType) => Promise; + handleSave: (data: LoginFormDataType) => void; loading?: boolean; }; diff --git a/src/pages/auth/login/index.page.tsx b/src/pages/auth/login/index.page.tsx index 2fb230992..3dcbf4440 100644 --- a/src/pages/auth/login/index.page.tsx +++ b/src/pages/auth/login/index.page.tsx @@ -4,9 +4,12 @@ import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import * as yup from "yup"; -import { useAuthContext } from "@/context/auth.provider"; +import { loginConnection } from "@/connections/Login"; +// import { useAuthContext } from "@/context/auth.provider"; import { ToastType, useToastContext } from "@/context/toast.provider"; +import { useConnection } from "@/hooks/useConnection"; import { useSetInviteToken } from "@/hooks/useInviteToken"; +import { useValueChanged } from "@/hooks/usePrevious"; import LoginLayout from "../layout"; import LoginForm from "./components/LoginForm"; @@ -27,35 +30,25 @@ const LoginPage = () => { useSetInviteToken(); const t = useT(); const router = useRouter(); - const { login, loginLoading } = useAuthContext(); + //const { login, loginLoading } = useAuthContext(); + const [, { isLoggedIn, isLoggingIn, loginFailed, login }] = useConnection(loginConnection); const { openToast } = useToastContext(); const form = useForm({ resolver: yupResolver(LoginFormDataSchema(t)), mode: "onSubmit" }); - /** - * Form Submit Handler - * @param data LoginFormData - * @returns Log in user and redirect to homepage - */ - const handleSave = async (data: LoginFormDataType) => { - const res = (await login( - { - email_address: data.email, - password: data.password - }, - () => openToast(t("Incorrect Email or Password"), ToastType.ERROR) - )) as { success: boolean }; - - if (!res?.success) return; - - return router.push("/home"); - }; + useValueChanged(loginFailed, () => { + if (loginFailed) openToast(t("Incorrect Email or Password"), ToastType.ERROR); + }); + + const handleSave = (data: LoginFormDataType) => login(data.email, data.password); + + if (isLoggedIn) return router.push("/home"); return ( - + ); }; diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts new file mode 100644 index 000000000..d8f40f8a9 --- /dev/null +++ b/src/store/apiSlice.ts @@ -0,0 +1,90 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { isArray } from "lodash"; + +import { LoginResponse } from "@/generated/v3/userService/userServiceSchemas"; + +export type PendingErrorState = { + statusCode: number; + message: string; + error?: string; +}; + +export type Pending = true | PendingErrorState; + +export const isInProgress = (pending?: Pending) => pending === true; + +export const isErrorState = (pending?: Pending): pending is PendingErrorState => + pending != null && !isInProgress(pending); + +export type Method = "GET" | "DELETE" | "POST" | "PUT" | "PATCH"; + +export type ApiPendingStore = { + [key in Method]: Record; +}; + +// The list of potential resource types. Each of these resources must be included in ApiDataStore, +// with a mapping to the response type for that resource. +export const RESOURCES = ["logins"] as const; + +export type JsonApiResource = { + type: (typeof RESOURCES)[number]; + id: string; +}; + +export type JsonApiResponse = { + data: JsonApiResource[] | JsonApiResource; +}; + +export type ApiDataStore = { + logins: Record; + + meta: { + pending: ApiPendingStore; + }; +}; + +const initialState: ApiDataStore = { + logins: {}, + + meta: { + pending: { + GET: {}, + DELETE: {}, + POST: {}, + PUT: {}, + PATCH: {} + } + } +}; + +const apiSlice = createSlice({ + name: "api", + initialState, + reducers: { + apiFetchStarting: (state, action: PayloadAction<{ url: string; method: Method }>) => { + const { url, method } = action.payload; + state.meta.pending[method][url] = true; + }, + apiFetchFailed: (state, action: PayloadAction<{ url: string; method: Method; error: PendingErrorState }>) => { + const { url, method, error } = action.payload; + state.meta.pending[method][url] = error; + }, + apiFetchSucceeded: (state, action: PayloadAction<{ url: string; method: Method; response: JsonApiResponse }>) => { + const { url, method, response } = action.payload; + delete state.meta.pending[method][url]; + + // All response objects from the v3 api conform to JsonApiResponse + let { data } = response; + if (!isArray(data)) data = [data]; + for (const resource of data) { + // The data resource type is expected to match what is declared above in ApiDataStore, but + // there isn't a way to enforce that with TS against this dynamic data structure, so we + // use the dreaded any. + state[resource.type][resource.id] = resource as any; + } + } + } +}); + +export const { apiFetchStarting, apiFetchFailed, apiFetchSucceeded } = apiSlice.actions; +export const apiReducer = apiSlice.reducer; diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 000000000..8a97b3ccc --- /dev/null +++ b/src/store/store.ts @@ -0,0 +1,11 @@ +import { configureStore } from "@reduxjs/toolkit"; +import { logger } from "redux-logger"; + +import { apiReducer } from "@/store/apiSlice"; + +export default configureStore({ + reducer: { + api: apiReducer + }, + middleware: getDefaultMiddleware => getDefaultMiddleware().concat(logger) +}); diff --git a/src/types/connection.ts b/src/types/connection.ts new file mode 100644 index 000000000..54612872a --- /dev/null +++ b/src/types/connection.ts @@ -0,0 +1,16 @@ +import { ApiDataStore } from "@/store/apiSlice"; + +export type OptionalProps = Record | undefined; + +export type Selector = ( + state: ApiDataStore, + props: PropsType +) => SelectedType; + +export type Connection = { + selector: Selector; + isLoaded?: (selected: SelectedType, props: PropsType) => boolean; + load?: (selected: SelectedType, props: PropsType) => void; +}; + +export type Connected = readonly [boolean, SelectedType | Record]; diff --git a/yarn.lock b/yarn.lock index 9db16060e..3a7fe676c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2822,9 +2822,9 @@ typescript "4.8.2" "@openapi-codegen/typescript@^6.1.0": - version "6.1.0" - resolved "https://registry.yarnpkg.com/@openapi-codegen/typescript/-/typescript-6.1.0.tgz#66850506a89a2a2f24a45db6a7a3ea21357de758" - integrity sha512-zwCw06hhk8BFS4DMOmOCuAFU6rfWql2M4VL7RxaqEsDWopi1GLtEJpKmRNUplv3aGGq/OLdJs1F9VdSitI1W2A== + version "6.2.4" + resolved "https://registry.yarnpkg.com/@openapi-codegen/typescript/-/typescript-6.2.4.tgz#0004c450486f16e76bbef3b278bb32bebdc7aff7" + integrity sha512-wh/J7Ij/furDIYo0yD8SjUZBCHn2+cu7N4cTKJ9M/PW7jaDYHyZk1ThYmtCFAVF2f3Jobpb51+3E0Grk8nqhpA== dependencies: case "^1.6.3" lodash "^4.17.21" @@ -2871,6 +2871,16 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== +"@reduxjs/toolkit@^2.2.7": + version "2.2.7" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.2.7.tgz#199e3d10ccb39267cb5aee92c0262fd9da7fdfb2" + integrity sha512-faI3cZbSdFb8yv9dhDTmGwclW0vk0z5o1cia+kf7gCbaCwHI5e+7tP57mJUv22pNcNbeA62GSrPpfrUfdXcQ6g== + dependencies: + immer "^10.0.3" + redux "^5.0.1" + redux-thunk "^3.1.0" + reselect "^5.1.0" + "@remirror/core-constants@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@remirror/core-constants/-/core-constants-2.0.2.tgz#f05eccdc69e3a65e7d524b52548f567904a11a1a" @@ -4963,6 +4973,11 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/pluralize@^0.0.33": + version "0.0.33" + resolved "https://registry.yarnpkg.com/@types/pluralize/-/pluralize-0.0.33.tgz#8ad9018368c584d268667dd9acd5b3b806e8c82a" + integrity sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg== + "@types/prettier@^2.1.5": version "2.7.2" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.2.tgz#6c2324641cc4ba050a8c710b2b251b377581fbf0" @@ -5053,6 +5068,13 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/redux-logger@^3.0.13": + version "3.0.13" + resolved "https://registry.yarnpkg.com/@types/redux-logger/-/redux-logger-3.0.13.tgz#473e98428cdcc6dc93c908de66732bf932e36bc8" + integrity sha512-jylqZXQfMxahkuPcO8J12AKSSCQngdEWQrw7UiLUJzMBcv1r4Qg77P6mjGLjM27e5gFQDPD8vwUMJ9AyVxFSsg== + dependencies: + redux "^5.0.0" + "@types/scheduler@*": version "0.16.2" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" @@ -5108,6 +5130,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.8.tgz#bb197b9639aa1a04cf464a617fe800cccd92ad5c" integrity sha512-d0XxK3YTObnWVp6rZuev3c49+j4Lo8g4L1ZRm9z5L0xpoZycUPshHgczK5gsUMaZOstjVYYi09p5gYvUtfChYw== +"@types/use-sync-external-store@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43" + integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== + "@types/uuid@^9.0.8": version "9.0.8" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba" @@ -7211,6 +7238,11 @@ dedent@^0.7.0: resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" integrity sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA== +deep-diff@^0.3.5: + version "0.3.8" + resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-0.3.8.tgz#c01de63efb0eec9798801d40c7e0dae25b582c84" + integrity sha512-yVn6RZmHiGnxRKR9sJb3iVV2XTF1Ghh2DiWRZ3dMnGc43yUdWWF/kX6lQyk3+P84iprfWKU/8zFTrlkvtFm1ug== + deep-equal@^2.0.5: version "2.2.0" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.0.tgz#5caeace9c781028b9ff459f33b779346637c43e6" @@ -9243,6 +9275,11 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== +immer@^10.0.3: + version "10.1.1" + resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc" + integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw== + import-fresh@^3.0.0, import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" @@ -13440,6 +13477,14 @@ react-reconciler@^0.26.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-redux@^9.1.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.1.2.tgz#deba38c64c3403e9abd0c3fbeab69ffd9d8a7e4b" + integrity sha512-0OA4dhM1W48l3uzmv6B7TXPCGmokUU4p1M44DGN2/D9a1FjVPukVjER1PcPX97jIg6aUeLq1XJo1IpfbgULn0w== + dependencies: + "@types/use-sync-external-store" "^0.0.3" + use-sync-external-store "^1.0.0" + react-refresh@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046" @@ -13601,6 +13646,23 @@ redeyed@~2.1.0: dependencies: esprima "~4.0.0" +redux-logger@^3.0.6: + version "3.0.6" + resolved "https://registry.yarnpkg.com/redux-logger/-/redux-logger-3.0.6.tgz#f7555966f3098f3c88604c449cf0baf5778274bf" + integrity sha512-JoCIok7bg/XpqA1JqCqXFypuqBbQzGQySrhFzewB7ThcnysTO30l4VCst86AuB9T9tuT03MAA56Jw2PNhRSNCg== + dependencies: + deep-diff "^0.3.5" + +redux-thunk@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" + integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== + +redux@^5.0.0, redux@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" + integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== + reftools@^1.1.9: version "1.1.9" resolved "https://registry.yarnpkg.com/reftools/-/reftools-1.1.9.tgz#e16e19f662ccd4648605312c06d34e5da3a2b77e" @@ -13759,6 +13821,11 @@ reselect@^4.1.8: resolved "http://registry.chelsea-apps.com:4873/reselect/-/reselect-4.1.8.tgz#3f5dc671ea168dccdeb3e141236f69f02eaec524" integrity sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ== +reselect@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e" + integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w== + resolve-alpn@^1.2.0: version "1.2.1" resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9" @@ -15502,6 +15569,11 @@ use-resize-observer@^9.1.0: dependencies: "@juggle/resize-observer" "^3.3.1" +use-sync-external-store@^1.0.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9" + integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw== + use-sync-external-store@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" From 321cbcde782d68be1e3253cf4f8aac15b6af271c Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 20 Sep 2024 23:36:12 -0700 Subject: [PATCH 005/102] [TM-1272] Functional login/logout. --- src/admin/apiProvider/authProvider.ts | 22 ++--- src/connections/Login.ts | 56 ++++++------ src/generated/v3/utils.ts | 22 ++--- src/hooks/logout.ts | 4 +- src/hooks/useConnection.ts | 6 +- src/hooks/usePrevious.ts | 9 +- src/pages/_app.tsx | 23 +++-- src/pages/auth/login/index.page.tsx | 13 ++- src/store/StoreProvider.tsx | 21 +++++ src/store/apiSlice.ts | 118 +++++++++++++++++++++----- src/store/store.ts | 35 ++++++-- 11 files changed, 217 insertions(+), 112 deletions(-) create mode 100644 src/store/StoreProvider.tsx diff --git a/src/admin/apiProvider/authProvider.ts b/src/admin/apiProvider/authProvider.ts index 3b41fb675..a61301080 100644 --- a/src/admin/apiProvider/authProvider.ts +++ b/src/admin/apiProvider/authProvider.ts @@ -1,6 +1,5 @@ import { AuthProvider } from "react-admin"; -import { isAdmin } from "@/admin/apiProvider/utils/user"; import { fetchGetAuthLogout, fetchGetAuthMe, fetchPostAuthLogin } from "@/generated/apiComponents"; import { AdminTokenStorageKey, removeAccessToken, setAccessToken } from "./utils/token"; @@ -31,15 +30,18 @@ export const authProvider: AuthProvider = { const token = localStorage.getItem(AdminTokenStorageKey); if (!token) return Promise.reject(); - return new Promise((resolve, reject) => { - fetchGetAuthMe({}) - .then(res => { - //@ts-ignore - if (isAdmin(res.data.role)) resolve(); - else reject("Only admins are allowed."); - }) - .catch(() => reject()); - }); + // TODO (TM-1312) Once we have a connection for the users/me object, we can check the cached + // value without re-fetching on every navigation in the admin UI. The previous implementation + // is included below for reference until that ticket is complete. + // return new Promise((resolve, reject) => { + // fetchGetAuthMe({}) + // .then(res => { + // //@ts-ignore + // if (isAdmin(res.data.role)) resolve(); + // else reject("Only admins are allowed."); + // }) + // .catch(() => reject()); + // }); }, // remove local credentials and notify the auth server that the user logged out logout: async () => { diff --git a/src/connections/Login.ts b/src/connections/Login.ts index 5b551150d..16ddeface 100644 --- a/src/connections/Login.ts +++ b/src/connections/Login.ts @@ -1,47 +1,39 @@ import { createSelector } from "reselect"; +import { removeAccessToken } from "@/admin/apiProvider/utils/token"; import { authLogin } from "@/generated/v3/userService/userServiceComponents"; import { authLoginFetchFailed, authLoginIsFetching } from "@/generated/v3/userService/userServicePredicates"; +import ApiSlice, { ApiDataStore } from "@/store/apiSlice"; import { Connection } from "@/types/connection"; type LoginConnection = { isLoggingIn: boolean; isLoggedIn: boolean; loginFailed: boolean; - login: (emailAddress: string, password: string) => void; + token?: string; }; -const login = (emailAddress: string, password: string) => authLogin({ body: { emailAddress, password } }); - -export const loginConnection: Connection = { - selector: createSelector([authLoginIsFetching, authLoginFetchFailed], (isLoggingIn, failedLogin) => { - return { - isLoggingIn, - // TODO get from auth token - isLoggedIn: false, - loginFailed: failedLogin != null, +export const login = (emailAddress: string, password: string) => authLogin({ body: { emailAddress, password } }); +export const logout = () => { + removeAccessToken(); + ApiSlice.clearApiCache(); +}; - login - }; - }) +const selectFirstLogin = (state: ApiDataStore) => { + const values = Object.values(state.logins); + return values.length < 1 ? null : values[0]; +}; - // selector(state: ApiDataStore): LoginConnection { - // const values = Object.values(state.logins); - // if (values.length > 1) { - // console.error("More than one Login recorded in the store!", state.logins); - // } - // - // // TODO We don't actually want the token to be part of the shape in this case, or to come from - // // the store. The token should always be fetched from local storage so that logins persist. - // const authToken = values[0]?.token; - // return { - // authToken, - // isLoggingIn: authLoginIsFetching(state), - // isLoggedIn: authToken != null, - // loginFailed: authLoginFetchFailed(state) != null, - // login: (emailAddress: string, password: string) => { - // authLogin({ body: { emailAddress, password } }); - // } - // }; - // } +export const loginConnection: Connection = { + selector: createSelector( + [authLoginIsFetching, authLoginFetchFailed, selectFirstLogin], + (isLoggingIn, failedLogin, firstLogin) => { + return { + isLoggingIn, + isLoggedIn: firstLogin != null, + loginFailed: failedLogin != null, + token: firstLogin?.token + }; + } + ) }; diff --git a/src/generated/v3/utils.ts b/src/generated/v3/utils.ts index 20274c12f..87b3f8ea8 100644 --- a/src/generated/v3/utils.ts +++ b/src/generated/v3/utils.ts @@ -1,14 +1,4 @@ -import { - ApiDataStore, - apiFetchFailed, - apiFetchStarting, - apiFetchSucceeded, - isErrorState, - isInProgress, - Method, - PendingErrorState -} from "@/store/apiSlice"; -import store from "@/store/store"; +import ApiSlice, { ApiDataStore, isErrorState, isInProgress, Method, PendingErrorState } from "@/store/apiSlice"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; @@ -63,14 +53,14 @@ export function fetchFailed({ export async function dispatchRequest(url: string, requestInit: RequestInit) { const actionPayload = { url, method: requestInit.method as Method }; - store.dispatch(apiFetchStarting(actionPayload)); + ApiSlice.fetchStarting(actionPayload); try { const response = await window.fetch(url, requestInit); if (!response.ok) { const error = (await response.json()) as ErrorWrapper; - store.dispatch(apiFetchFailed({ ...actionPayload, error: error as PendingErrorState })); + ApiSlice.fetchFailed({ ...actionPayload, error: error as PendingErrorState }); return; } @@ -81,13 +71,13 @@ export async function dispatchRequest(url: string, requestInit: R const responsePayload = await response.json(); if (responsePayload.statusCode != null && responsePayload.message != null) { - store.dispatch(apiFetchFailed({ ...actionPayload, error: responsePayload })); + ApiSlice.fetchFailed({ ...actionPayload, error: responsePayload }); } else { - store.dispatch(apiFetchSucceeded({ ...actionPayload, response: responsePayload })); + ApiSlice.fetchSucceeded({ ...actionPayload, response: responsePayload }); } } catch (e) { console.error("Unexpected API fetch failure", e); const message = e instanceof Error ? `Network error (${e.message})` : "Network error"; - store.dispatch(apiFetchFailed({ ...actionPayload, error: { statusCode: -1, message } })); + ApiSlice.fetchFailed({ ...actionPayload, error: { statusCode: -1, message } }); } } diff --git a/src/hooks/logout.ts b/src/hooks/logout.ts index ec9834f37..b2938d82a 100644 --- a/src/hooks/logout.ts +++ b/src/hooks/logout.ts @@ -1,7 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/router"; -import { removeAccessToken } from "@/admin/apiProvider/utils/token"; +import { logout } from "@/connections/Login"; export const useLogout = () => { const queryClient = useQueryClient(); @@ -10,7 +10,7 @@ export const useLogout = () => { return () => { queryClient.getQueryCache().clear(); queryClient.clear(); - removeAccessToken(); + logout(); router.push("/"); window.location.replace("/"); }; diff --git a/src/hooks/useConnection.ts b/src/hooks/useConnection.ts index 926fc0a8d..e03552671 100644 --- a/src/hooks/useConnection.ts +++ b/src/hooks/useConnection.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; -import store from "@/store/store"; +import ApiSlice from "@/store/apiSlice"; import { Connected, Connection, OptionalProps } from "@/types/connection"; /** @@ -17,7 +17,7 @@ export function useConnection { - const connected = selector(store.getState().api, props); + const connected = selector(ApiSlice.store.getState().api, props); const loadingDone = isLoaded == null || isLoaded(connected, props); return { loadingDone, connected }; }, [isLoaded, props, selector]); @@ -40,7 +40,7 @@ export function useConnection(value: T): T | undefined { } export function useValueChanged(value: T, callback: () => void) { - const previous = usePrevious(value); - if (previous !== value) callback(); + const ref = useRef(); + useEffect(() => { + if (ref.current !== value) { + ref.current = value; + callback(); + } + }); } diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index c63cf3a5b..e20dbefc8 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -8,7 +8,6 @@ import App from "next/app"; import dynamic from "next/dynamic"; import { useRouter } from "next/router"; import nookies from "nookies"; -import { Provider as ReduxProvider } from "react-redux"; import Toast from "@/components/elements/Toast/Toast"; import ModalRoot from "@/components/extensive/Modal/ModalRoot"; @@ -22,14 +21,14 @@ import WrappedQueryClientProvider from "@/context/queryclient.provider"; import RouteHistoryProvider from "@/context/routeHistory.provider"; import ToastProvider from "@/context/toast.provider"; import { getServerSideTranslations, setClientSideTranslations } from "@/i18n"; -import store from "@/store/store"; +import StoreProvider from "@/store/StoreProvider"; import setupYup from "@/yup.locale"; const CookieBanner = dynamic(() => import("@/components/extensive/CookieBanner/CookieBanner"), { ssr: false }); -const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessToken?: string; props: any }) => { +const _App = ({ Component, pageProps, props, authToken }: AppProps & { authToken?: string; props: any }) => { const t = useT(); const router = useRouter(); const isAdmin = router.asPath.includes("/admin"); @@ -39,9 +38,9 @@ const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessT if (isAdmin) return ( - + - + @@ -52,15 +51,15 @@ const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessT - + ); else return ( - + - + @@ -68,8 +67,8 @@ const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessT - - + + @@ -82,7 +81,7 @@ const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessT - + ); }; @@ -95,7 +94,7 @@ _App.getInitialProps = async (context: AppContext) => { } catch (err) { console.log("Failed to get Serverside Transifex", err); } - return { ...ctx, props: { ...translationsData }, accessToken: cookies.accessToken }; + return { ...ctx, props: { ...translationsData }, authToken: cookies.accessToken }; }; export default _App; diff --git a/src/pages/auth/login/index.page.tsx b/src/pages/auth/login/index.page.tsx index 3dcbf4440..cd63ea567 100644 --- a/src/pages/auth/login/index.page.tsx +++ b/src/pages/auth/login/index.page.tsx @@ -4,8 +4,7 @@ import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import * as yup from "yup"; -import { loginConnection } from "@/connections/Login"; -// import { useAuthContext } from "@/context/auth.provider"; +import { login, loginConnection } from "@/connections/Login"; import { ToastType, useToastContext } from "@/context/toast.provider"; import { useConnection } from "@/hooks/useConnection"; import { useSetInviteToken } from "@/hooks/useInviteToken"; @@ -30,8 +29,7 @@ const LoginPage = () => { useSetInviteToken(); const t = useT(); const router = useRouter(); - //const { login, loginLoading } = useAuthContext(); - const [, { isLoggedIn, isLoggingIn, loginFailed, login }] = useConnection(loginConnection); + const [, { isLoggedIn, isLoggingIn, loginFailed }] = useConnection(loginConnection); const { openToast } = useToastContext(); const form = useForm({ resolver: yupResolver(LoginFormDataSchema(t)), @@ -41,14 +39,15 @@ const LoginPage = () => { useValueChanged(loginFailed, () => { if (loginFailed) openToast(t("Incorrect Email or Password"), ToastType.ERROR); }); + useValueChanged(isLoggedIn, () => { + if (isLoggedIn) router.push("/home"); + }); const handleSave = (data: LoginFormDataType) => login(data.email, data.password); - if (isLoggedIn) return router.push("/home"); - return ( - + ); }; diff --git a/src/store/StoreProvider.tsx b/src/store/StoreProvider.tsx new file mode 100644 index 000000000..dbd55e95f --- /dev/null +++ b/src/store/StoreProvider.tsx @@ -0,0 +1,21 @@ +"use client"; +import { useRef } from "react"; +import { Provider } from "react-redux"; + +import { AppStore, makeStore } from "./store"; + +export default function StoreProvider({ + authToken = undefined, + children +}: { + authToken?: string; + children: React.ReactNode; +}) { + const storeRef = useRef(); + if (!storeRef.current) { + // Create the store instance the first time this renders + storeRef.current = makeStore(authToken); + } + + return {children}; +} diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index d8f40f8a9..f11b36c83 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -1,6 +1,8 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { createListenerMiddleware, createSlice, PayloadAction } from "@reduxjs/toolkit"; import { isArray } from "lodash"; +import { Store } from "redux"; +import { setAccessToken } from "@/admin/apiProvider/utils/token"; import { LoginResponse } from "@/generated/v3/userService/userServiceSchemas"; export type PendingErrorState = { @@ -16,14 +18,15 @@ export const isInProgress = (pending?: Pending) => pending === true; export const isErrorState = (pending?: Pending): pending is PendingErrorState => pending != null && !isInProgress(pending); -export type Method = "GET" | "DELETE" | "POST" | "PUT" | "PATCH"; +const METHODS = ["GET", "DELETE", "POST", "PUT", "PATCH"] as const; +export type Method = (typeof METHODS)[number]; export type ApiPendingStore = { [key in Method]: Record; }; -// The list of potential resource types. Each of these resources must be included in ApiDataStore, -// with a mapping to the response type for that resource. +// The list of potential resource types. IMPORTANT: When a new resource type is integrated, it must +// be added to this list. export const RESOURCES = ["logins"] as const; export type JsonApiResource = { @@ -35,41 +38,54 @@ export type JsonApiResponse = { data: JsonApiResource[] | JsonApiResource; }; -export type ApiDataStore = { +type ApiResources = { logins: Record; +}; +export type ApiDataStore = ApiResources & { meta: { pending: ApiPendingStore; }; }; -const initialState: ApiDataStore = { - logins: {}, +const initialState = { + ...RESOURCES.reduce((acc: Partial, resource) => { + acc[resource] = {}; + return acc; + }, {}), meta: { - pending: { - GET: {}, - DELETE: {}, - POST: {}, - PUT: {}, - PATCH: {} - } + pending: METHODS.reduce((acc: Partial, method) => { + acc[method] = {}; + return acc; + }, {}) as ApiPendingStore } +} as ApiDataStore; + +type ApiFetchStartingProps = { + url: string; + method: Method; +}; +type ApiFetchFailedProps = ApiFetchStartingProps & { + error: PendingErrorState; +}; +type ApiFetchSucceededProps = ApiFetchStartingProps & { + response: JsonApiResponse; }; -const apiSlice = createSlice({ +export const apiSlice = createSlice({ name: "api", initialState, reducers: { - apiFetchStarting: (state, action: PayloadAction<{ url: string; method: Method }>) => { + apiFetchStarting: (state, action: PayloadAction) => { const { url, method } = action.payload; state.meta.pending[method][url] = true; }, - apiFetchFailed: (state, action: PayloadAction<{ url: string; method: Method; error: PendingErrorState }>) => { + apiFetchFailed: (state, action: PayloadAction) => { const { url, method, error } = action.payload; state.meta.pending[method][url] = error; }, - apiFetchSucceeded: (state, action: PayloadAction<{ url: string; method: Method; response: JsonApiResponse }>) => { + apiFetchSucceeded: (state, action: PayloadAction) => { const { url, method, response } = action.payload; delete state.meta.pending[method][url]; @@ -82,9 +98,71 @@ const apiSlice = createSlice({ // use the dreaded any. state[resource.type][resource.id] = resource as any; } + }, + + clearApiCache: state => { + for (const resource of RESOURCES) { + state[resource] = {}; + } + + for (const method of METHODS) { + state.meta.pending[method] = {}; + } + }, + + // only used during app bootup. + setInitialAuthToken: (state, action: PayloadAction<{ authToken: string }>) => { + const { authToken } = action.payload; + // We only ever expect there to be at most one Login in the store, and we never inspect the ID + // so we can safely fake a login into the store when we have an authToken already set in a + // cookie on app bootup. + state.logins["1"] = { id: "id", type: "logins", token: authToken }; } } }); -export const { apiFetchStarting, apiFetchFailed, apiFetchSucceeded } = apiSlice.actions; -export const apiReducer = apiSlice.reducer; +export const authListenerMiddleware = createListenerMiddleware(); +authListenerMiddleware.startListening({ + actionCreator: apiSlice.actions.apiFetchSucceeded, + effect: async ( + action: PayloadAction<{ + url: string; + method: Method; + response: JsonApiResponse; + }> + ) => { + const { url, method, response } = action.payload; + if (!url.endsWith("auth/v3/logins") || method !== "POST") return; + + const { data } = response as { data: LoginResponse }; + setAccessToken(data.token); + } +}); + +export default class ApiSlice { + private static _store: Store; + + static set store(store: Store) { + this._store = store; + } + + static get store(): Store { + return this._store; + } + + static fetchStarting(props: ApiFetchStartingProps) { + this.store.dispatch(apiSlice.actions.apiFetchStarting(props)); + } + + static fetchFailed(props: ApiFetchFailedProps) { + this.store.dispatch(apiSlice.actions.apiFetchFailed(props)); + } + + static fetchSucceeded(props: ApiFetchSucceededProps) { + this.store.dispatch(apiSlice.actions.apiFetchSucceeded(props)); + } + + static clearApiCache() { + this.store.dispatch(apiSlice.actions.clearApiCache()); + } +} diff --git a/src/store/store.ts b/src/store/store.ts index 8a97b3ccc..5b8da3850 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,11 +1,30 @@ import { configureStore } from "@reduxjs/toolkit"; import { logger } from "redux-logger"; -import { apiReducer } from "@/store/apiSlice"; - -export default configureStore({ - reducer: { - api: apiReducer - }, - middleware: getDefaultMiddleware => getDefaultMiddleware().concat(logger) -}); +import ApiSlice, { apiSlice, authListenerMiddleware } from "@/store/apiSlice"; + +export const makeStore = (authToken?: string) => { + const store = configureStore({ + reducer: { + api: apiSlice.reducer + }, + middleware: getDefaultMiddleware => { + if (process.env.NODE_ENV === "production") { + return getDefaultMiddleware().prepend(authListenerMiddleware.middleware); + } else { + return getDefaultMiddleware().prepend(authListenerMiddleware.middleware).concat(logger); + } + } + }); + + if (authToken != null) { + store.dispatch(apiSlice.actions.setInitialAuthToken({ authToken })); + } + + ApiSlice.store = store; + + return store; +}; + +// Infer the type of makeStore +export type AppStore = ReturnType; From 0624850c6527aed0d2a894d9146e344a7f895432 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 20 Sep 2024 23:38:54 -0700 Subject: [PATCH 006/102] [TM-1272] Rename hook. --- src/hooks/usePrevious.ts | 19 ------------------- src/hooks/useValueChanged.ts | 29 +++++++++++++++++++++++++++++ src/pages/auth/login/index.page.tsx | 2 +- 3 files changed, 30 insertions(+), 20 deletions(-) delete mode 100644 src/hooks/usePrevious.ts create mode 100644 src/hooks/useValueChanged.ts diff --git a/src/hooks/usePrevious.ts b/src/hooks/usePrevious.ts deleted file mode 100644 index 4ee40b4e5..000000000 --- a/src/hooks/usePrevious.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useEffect, useRef } from "react"; - -export function usePrevious(value: T): T | undefined { - const ref = useRef(); - useEffect(() => { - ref.current = value; - }, [value]); - return ref.current; -} - -export function useValueChanged(value: T, callback: () => void) { - const ref = useRef(); - useEffect(() => { - if (ref.current !== value) { - ref.current = value; - callback(); - } - }); -} diff --git a/src/hooks/useValueChanged.ts b/src/hooks/useValueChanged.ts new file mode 100644 index 000000000..d06a0f793 --- /dev/null +++ b/src/hooks/useValueChanged.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef } from "react"; + +/** + * A hook useful for executing a side effect after component render (in an effect) if the given + * value changes. Uses strict equals. The primary use of this hook is to prevent a side effect from + * being executed multiple times if the component re-renders after the value has transitioned to its + * action state. + * + * Callback is guaranteed to execute on the first render of the component. This is intentional. A + * consumer of this hook is expected to check the current state of the value and take action based + * on its current state. If the component initially renders with the value in the action state, we'd + * want the resulting side effect to take place immediately, rather than only when the value has + * changed. + * + * Example: + * + * useValueChanged(isLoggedIn, () => { + * if (isLoggedIn) router.push("/home"); + * } + */ +export function useValueChanged(value: T, callback: () => void) { + const ref = useRef(); + useEffect(() => { + if (ref.current !== value) { + ref.current = value; + callback(); + } + }); +} diff --git a/src/pages/auth/login/index.page.tsx b/src/pages/auth/login/index.page.tsx index cd63ea567..9f7fdb55f 100644 --- a/src/pages/auth/login/index.page.tsx +++ b/src/pages/auth/login/index.page.tsx @@ -8,7 +8,7 @@ import { login, loginConnection } from "@/connections/Login"; import { ToastType, useToastContext } from "@/context/toast.provider"; import { useConnection } from "@/hooks/useConnection"; import { useSetInviteToken } from "@/hooks/useInviteToken"; -import { useValueChanged } from "@/hooks/usePrevious"; +import { useValueChanged } from "@/hooks/useValueChanged"; import LoginLayout from "../layout"; import LoginForm from "./components/LoginForm"; From bd5127efd025054e98495b83e486b057ab653df4 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 20 Sep 2024 23:56:36 -0700 Subject: [PATCH 007/102] [TM-1272] Remove some props passing of isLoggedIn. --- src/components/generic/Layout/MainLayout.tsx | 6 ++---- src/components/generic/Navbar/Navbar.tsx | 9 ++------- src/components/generic/Navbar/NavbarContent.tsx | 6 ++++-- src/pages/_app.tsx | 4 ++-- 4 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/components/generic/Layout/MainLayout.tsx b/src/components/generic/Layout/MainLayout.tsx index 9bc770e3e..8f289427c 100644 --- a/src/components/generic/Layout/MainLayout.tsx +++ b/src/components/generic/Layout/MainLayout.tsx @@ -2,14 +2,12 @@ import { DetailedHTMLProps, HTMLAttributes, PropsWithChildren } from "react"; import Navbar from "@/components/generic/Navbar/Navbar"; -interface MainLayoutProps extends DetailedHTMLProps, HTMLDivElement> { - isLoggedIn?: boolean; -} +interface MainLayoutProps extends DetailedHTMLProps, HTMLDivElement> {} const MainLayout = (props: PropsWithChildren) => { return (
- +
{props.children}
); diff --git a/src/components/generic/Navbar/Navbar.tsx b/src/components/generic/Navbar/Navbar.tsx index 6c39f2a27..229be519f 100644 --- a/src/components/generic/Navbar/Navbar.tsx +++ b/src/components/generic/Navbar/Navbar.tsx @@ -10,11 +10,7 @@ import { useNavbarContext } from "@/context/navbar.provider"; import Container from "../Layout/Container"; import NavbarContent from "./NavbarContent"; -export interface NavbarProps { - isLoggedIn?: boolean; -} - -const Navbar = (props: NavbarProps): JSX.Element => { +const Navbar = (): JSX.Element => { const { isOpen, setIsOpen, linksDisabled } = useNavbarContext(); const isLg = useMediaQuery("(min-width:1024px)"); @@ -37,7 +33,7 @@ const Navbar = (props: NavbarProps): JSX.Element => { - + @@ -68,7 +64,6 @@ const Navbar = (props: NavbarProps): JSX.Element => { "relative flex flex-col items-center justify-center gap-4 sm:hidden", isOpen && "h-[calc(100vh-70px)]" )} - isLoggedIn={props.isLoggedIn} handleClose={() => setIsOpen?.(false)} /> diff --git a/src/components/generic/Navbar/NavbarContent.tsx b/src/components/generic/Navbar/NavbarContent.tsx index 7372fa387..2ce838f26 100644 --- a/src/components/generic/Navbar/NavbarContent.tsx +++ b/src/components/generic/Navbar/NavbarContent.tsx @@ -7,8 +7,10 @@ import { Else, If, Then, When } from "react-if"; import LanguagesDropdown from "@/components/elements/Inputs/LanguageDropdown/LanguagesDropdown"; import { IconNames } from "@/components/extensive/Icon/Icon"; import List from "@/components/extensive/List/List"; +import { loginConnection } from "@/connections/Login"; import { useNavbarContext } from "@/context/navbar.provider"; import { useLogout } from "@/hooks/logout"; +import { useConnection } from "@/hooks/useConnection"; import { useMyOrg } from "@/hooks/useMyOrg"; import { OptionValue } from "@/types/common"; @@ -16,11 +18,11 @@ import NavbarItem from "./NavbarItem"; import { getNavbarItems } from "./navbarItems"; interface NavbarContentProps extends DetailedHTMLProps, HTMLDivElement> { - isLoggedIn?: boolean; handleClose?: () => void; } -const NavbarContent = ({ isLoggedIn, handleClose, ...rest }: NavbarContentProps) => { +const NavbarContent = ({ handleClose, ...rest }: NavbarContentProps) => { + const [, { isLoggedIn }] = useConnection(loginConnection); const router = useRouter(); const t = useT(); const myOrg = useMyOrg(); diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index e20dbefc8..4abf432f6 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -67,8 +67,8 @@ const _App = ({ Component, pageProps, props, authToken }: AppProps & { authToken - - + + From 945e3140fae549ec37bf13ea01405f68738a920a Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 23 Sep 2024 10:29:52 -0700 Subject: [PATCH 008/102] [TM-1272] Remove old AuthContext --- src/context/auth.provider.test.tsx | 51 -------------------------- src/context/auth.provider.tsx | 59 ------------------------------ src/generated/apiContext.ts | 6 +-- src/hooks/useUserData.ts | 8 +++- src/pages/_app.tsx | 53 ++++++++++++--------------- 5 files changed, 33 insertions(+), 144 deletions(-) delete mode 100644 src/context/auth.provider.test.tsx delete mode 100644 src/context/auth.provider.tsx diff --git a/src/context/auth.provider.test.tsx b/src/context/auth.provider.test.tsx deleted file mode 100644 index 9ccade836..000000000 --- a/src/context/auth.provider.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { renderHook } from "@testing-library/react"; - -import AuthProvider, { useAuthContext } from "@/context/auth.provider"; -import * as api from "@/generated/apiComponents"; - -jest.mock("@/generated/apiComponents", () => ({ - __esModule: true, - useGetAuthMe: jest.fn(), - usePostAuthLogin: jest.fn() -})); - -jest.mock("@/generated/apiFetcher", () => ({ - __esModule: true, - apiFetch: jest.fn() -})); - -describe("Test auth.provider context", () => { - const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9"; - const userData = { - uuid: "1234-1234", - name: "3SC" - }; - - beforeEach(() => { - jest.resetAllMocks(); - //@ts-ignore - api.usePostAuthLogin.mockImplementation(() => ({ - mutateAsync: jest.fn(() => Promise.resolve({ data: { token } })), - isLoading: false, - error: null - })); - //@ts-ignore - api.useGetAuthMe.mockReturnValue({ - data: { - data: userData - } - }); - }); - - test("login method update local storage", async () => { - const { result } = renderHook(() => useAuthContext(), { - wrapper: props => {props.children} - }); - - jest.spyOn(window.localStorage.__proto__, "setItem"); - - await result.current.login({ email_address: "example@3sidedcube.com", password: "12345" }); - - expect(localStorage.setItem).toBeCalledWith("access_token", token); - }); -}); diff --git a/src/context/auth.provider.tsx b/src/context/auth.provider.tsx deleted file mode 100644 index 019d483ba..000000000 --- a/src/context/auth.provider.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { createContext, useContext } from "react"; - -import { setAccessToken } from "@/admin/apiProvider/utils/token"; -import { usePostAuthLogin } from "@/generated/apiComponents"; -import { AuthLogIn } from "@/generated/apiSchemas"; - -interface IAuthContext { - login: (body: AuthLogIn, onError?: () => void) => Promise; - loginLoading: boolean; - token?: string; -} - -export const AuthContext = createContext({ - login: async () => {}, - loginLoading: false, - token: "" -}); - -type AuthProviderProps = { children: React.ReactNode; token?: string }; - -const AuthProvider = ({ children, token }: AuthProviderProps) => { - const { mutateAsync: authLogin, isLoading: loginLoading } = usePostAuthLogin(); - - const login = async (body: AuthLogIn, onError?: () => void) => { - return new Promise(r => { - authLogin({ - body - }) - .then(res => { - // @ts-expect-error - const token = res["data"].token; - - setAccessToken(token); - - r({ success: true }); - }) - .catch(() => { - onError?.(); - r({ success: false }); - }); - }); - }; - - return ( - - {children} - - ); -}; - -export const useAuthContext = () => useContext(AuthContext); - -export default AuthProvider; diff --git a/src/generated/apiContext.ts b/src/generated/apiContext.ts index 0a2bac201..b1bbb667a 100644 --- a/src/generated/apiContext.ts +++ b/src/generated/apiContext.ts @@ -1,8 +1,8 @@ import type { QueryKey, UseQueryOptions } from "@tanstack/react-query"; -import { useAuthContext } from "@/context/auth.provider"; - import { QueryOperation } from "./apiComponents"; +import { useConnection } from "@/hooks/useConnection"; +import { loginConnection } from "@/connections/Login"; export type ApiContext = { fetcherOptions: { @@ -41,7 +41,7 @@ export function useApiContext< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey >(_queryOptions?: Omit, "queryKey" | "queryFn">): ApiContext { - const { token } = useAuthContext(); + const [, { token }] = useConnection(loginConnection); return { fetcherOptions: { diff --git a/src/hooks/useUserData.ts b/src/hooks/useUserData.ts index ef1df550c..ec1db36fa 100644 --- a/src/hooks/useUserData.ts +++ b/src/hooks/useUserData.ts @@ -1,13 +1,17 @@ -import { useAuthContext } from "@/context/auth.provider"; +import { loginConnection } from "@/connections/Login"; import { useGetAuthMe } from "@/generated/apiComponents"; import { MeResponse } from "@/generated/apiSchemas"; +import { useConnection } from "@/hooks/useConnection"; /** * To easily access user data * @returns MeResponse + * + * TODO This hooks will be replaced in TM-1312, and the user data will be cached instead of re-fetched + * every 5 minutes for every component that uses this hook. */ export const useUserData = () => { - const { token } = useAuthContext(); + const [, { token }] = useConnection(loginConnection); const { data: authMe } = useGetAuthMe<{ data: MeResponse }>( {}, { diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 4abf432f6..cc27465b7 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -12,7 +12,6 @@ import nookies from "nookies"; import Toast from "@/components/elements/Toast/Toast"; import ModalRoot from "@/components/extensive/Modal/ModalRoot"; import MainLayout from "@/components/generic/Layout/MainLayout"; -import AuthProvider from "@/context/auth.provider"; import { LoadingProvider } from "@/context/loaderAdmin.provider"; import ModalProvider from "@/context/modal.provider"; import NavbarProvider from "@/context/navbar.provider"; @@ -40,16 +39,14 @@ const _App = ({ Component, pageProps, props, authToken }: AppProps & { authToken return ( - - - - - - - - - - + + + + + + + + ); @@ -59,24 +56,22 @@ const _App = ({ Component, pageProps, props, authToken }: AppProps & { authToken - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + From 079b614028743914c2825053204a77b92208e21e Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 23 Sep 2024 11:37:18 -0700 Subject: [PATCH 009/102] [TM-1272] Replaced console usage with a new central logger. --- src/admin/apiProvider/authProvider.ts | 19 +++------- src/admin/apiProvider/utils/error.ts | 5 +-- .../Dialogs/FormQuestionPreviewDialog.tsx | 3 +- .../Dialogs/FormSectionPreviewDialog.tsx | 3 +- .../SiteInformationAside/QuickActions.tsx | 3 +- .../PolygonDrawer/PolygonDrawer.tsx | 5 ++- .../components/AttributeInformation.tsx | 3 +- .../PolygonStatus/StatusDisplay.tsx | 5 ++- .../ResourceTabs/PolygonReviewTab/index.tsx | 9 +++-- .../form/components/CopyFormToOtherEnv.tsx | 5 ++- .../elements/Accordion/Accordion.stories.tsx | 4 +- .../ImageGallery/ImageGalleryItem.tsx | 5 ++- .../Inputs/Dropdown/Dropdown.stories.tsx | 3 +- .../Inputs/FileInput/RHFFileInput.tsx | 3 +- .../elements/Inputs/Select/Select.stories.tsx | 4 +- .../SelectImage/SelectImage.stories.tsx | 4 +- .../TreeSpeciesInput.stories.tsx | 6 ++- .../elements/Map-mapbox/Map.stories.tsx | 5 ++- .../CheckIndividualPolygonControl.tsx | 3 +- .../MapControls/CheckPolygonControl.tsx | 3 +- .../Map-mapbox/MapLayers/GeoJsonLayer.tsx | 3 +- src/components/elements/Map-mapbox/utils.ts | 3 +- .../MapPolygonPanel/AttributeInformation.tsx | 3 +- .../MapPolygonPanel/ChecklistInformation.tsx | 3 +- .../MapPolygonPanel.stories.tsx | 6 ++- .../MapSidePanel/MapSidePanel.stories.tsx | 14 ++++--- .../EntityMapAndGalleryCard.tsx | 3 +- .../extensive/Modal/FormModal.stories.tsx | 3 +- .../extensive/Modal/Modal.stories.tsx | 6 ++- .../extensive/Modal/ModalImageDetails.tsx | 3 +- .../Modal/ModalWithClose.stories.tsx | 6 ++- .../extensive/Modal/ModalWithLogo.stories.tsx | 6 ++- .../Pagination/PerPageSelector.stories.tsx | 4 +- .../WizardForm/WizardForm.stories.tsx | 9 +++-- src/components/extensive/WizardForm/index.tsx | 9 ++--- src/constants/options/frameworks.ts | 3 +- src/context/mapArea.provider.tsx | 3 +- src/generated/apiFetcher.ts | 9 ++--- src/generated/v3/utils.ts | 3 +- .../useGetCustomFormSteps.stories.tsx | 5 ++- src/hooks/useMessageValidations.ts | 4 +- src/pages/_app.tsx | 3 +- .../components/ApplicationHeader.tsx | 3 +- src/pages/auth/verify/email/[token].page.tsx | 3 +- src/pages/debug/index.page.tsx | 5 ++- src/pages/site/[uuid]/tabs/Overview.tsx | 5 ++- src/utils/geojson.ts | 2 +- src/utils/log.ts | 37 +++++++++++++++++++ src/utils/network.ts | 6 ++- 49 files changed, 175 insertions(+), 97 deletions(-) create mode 100644 src/utils/log.ts diff --git a/src/admin/apiProvider/authProvider.ts b/src/admin/apiProvider/authProvider.ts index a61301080..90922d014 100644 --- a/src/admin/apiProvider/authProvider.ts +++ b/src/admin/apiProvider/authProvider.ts @@ -1,23 +1,14 @@ import { AuthProvider } from "react-admin"; -import { fetchGetAuthLogout, fetchGetAuthMe, fetchPostAuthLogin } from "@/generated/apiComponents"; +import { fetchGetAuthLogout, fetchGetAuthMe } from "@/generated/apiComponents"; +import Log from "@/utils/log"; -import { AdminTokenStorageKey, removeAccessToken, setAccessToken } from "./utils/token"; +import { AdminTokenStorageKey, removeAccessToken } from "./utils/token"; export const authProvider: AuthProvider = { // send username and password to the auth server and get back credentials - login: params => { - return fetchPostAuthLogin({ body: { email_address: params.username, password: params.password } }) - .then(async res => { - //@ts-ignore - const token = res.data.token; - - setAccessToken(token); - }) - .catch(e => { - console.log(e); - throw Error("Wrong username or password"); - }); + login: async params => { + Log.error("Admin app does not support direct login"); }, // when the dataProvider returns an error, check if this is an authentication error diff --git a/src/admin/apiProvider/utils/error.ts b/src/admin/apiProvider/utils/error.ts index 204945203..7a1841e22 100644 --- a/src/admin/apiProvider/utils/error.ts +++ b/src/admin/apiProvider/utils/error.ts @@ -1,10 +1,9 @@ -import * as Sentry from "@sentry/nextjs"; import { HttpError } from "react-admin"; import { ErrorWrapper } from "@/generated/apiFetcher"; +import Log from "@/utils/log"; export const getFormattedErrorForRA = (err: ErrorWrapper) => { - console.log(err); - Sentry.captureException(err); + Log.error("Network error", err?.statusCode, ...(err?.errors ?? [])); return new HttpError(err?.errors?.map?.(e => e.detail).join(", ") || "", err?.statusCode); }; diff --git a/src/admin/components/Dialogs/FormQuestionPreviewDialog.tsx b/src/admin/components/Dialogs/FormQuestionPreviewDialog.tsx index 6d8872f36..2a5ac4076 100644 --- a/src/admin/components/Dialogs/FormQuestionPreviewDialog.tsx +++ b/src/admin/components/Dialogs/FormQuestionPreviewDialog.tsx @@ -18,6 +18,7 @@ import { FieldMapper } from "@/components/extensive/WizardForm/FieldMapper"; import ModalProvider from "@/context/modal.provider"; import { FormQuestionRead, V2GenericList } from "@/generated/apiSchemas"; import { apiFormQuestionToFormField } from "@/helpers/customForms"; +import Log from "@/utils/log"; interface ConfirmationDialogProps extends DialogProps { question?: FormQuestionRead; @@ -54,7 +55,7 @@ export const FormQuestionPreviewDialog = ({ - + Log.debug("Field Mapper onChange")} /> diff --git a/src/admin/components/Dialogs/FormSectionPreviewDialog.tsx b/src/admin/components/Dialogs/FormSectionPreviewDialog.tsx index c2b05f29f..05c3cdefb 100644 --- a/src/admin/components/Dialogs/FormSectionPreviewDialog.tsx +++ b/src/admin/components/Dialogs/FormSectionPreviewDialog.tsx @@ -18,6 +18,7 @@ import { FormStep } from "@/components/extensive/WizardForm/FormStep"; import ModalProvider from "@/context/modal.provider"; import { FormSectionRead, V2GenericList } from "@/generated/apiSchemas"; import { apiFormSectionToFormStep } from "@/helpers/customForms"; +import Log from "@/utils/log"; interface ConfirmationDialogProps extends DialogProps { section?: FormSectionRead; @@ -49,7 +50,7 @@ export const FormSectionPreviewDialog = ({ linkedFieldData, section: _section, . - + Log.debug("FormStep onChange")} /> diff --git a/src/admin/components/ResourceTabs/InformationTab/components/SiteInformationAside/QuickActions.tsx b/src/admin/components/ResourceTabs/InformationTab/components/SiteInformationAside/QuickActions.tsx index f8428e6dc..86c20f42b 100644 --- a/src/admin/components/ResourceTabs/InformationTab/components/SiteInformationAside/QuickActions.tsx +++ b/src/admin/components/ResourceTabs/InformationTab/components/SiteInformationAside/QuickActions.tsx @@ -4,6 +4,7 @@ import { Button, Labeled, Link, NumberField, useCreatePath, useShowContext } fro import modules from "@/admin/modules"; import Text from "@/components/elements/Text/Text"; +import Log from "@/utils/log"; import { downloadFileBlob } from "@/utils/network"; const QuickActions: FC = () => { const { record } = useShowContext(); @@ -35,7 +36,7 @@ const QuickActions: FC = () => { downloadFileBlob(record.boundary_geojson, `${record.name}_shapefile.geojson`); } } catch (error) { - console.error("Error downloading shapefile:", error); + Log.error("Error downloading shapefile:", error); } }; diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/PolygonDrawer.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/PolygonDrawer.tsx index da1e0de09..3622f8d4f 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/PolygonDrawer.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/PolygonDrawer.tsx @@ -24,6 +24,7 @@ import { } from "@/generated/apiComponents"; import { ClippedPolygonsResponse, SitePolygon, SitePolygonsDataResponse } from "@/generated/apiSchemas"; import { parseValidationData } from "@/helpers/polygonValidation"; +import Log from "@/utils/log"; import CommentarySection from "../CommentarySection/CommentarySection"; import StatusDisplay from "../PolygonStatus/StatusDisplay"; @@ -154,7 +155,7 @@ const PolygonDrawer = ({ hideLoader(); }, onError: error => { - console.error("Error clipping polygons:", error); + Log.error("Error clipping polygons:", error); openNotification("error", t("Error! Could not fix polygons"), t("Please try again later.")); } }); @@ -258,7 +259,7 @@ const PolygonDrawer = ({ showLoader(); clipPolygons({ pathParams: { uuid: polygonSelected } }); } else { - console.error("Polygon UUID is missing"); + Log.error("Polygon UUID is missing"); openNotification("error", t("Error"), t("Cannot fix polygons: Polygon UUID is missing.")); } }; diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/AttributeInformation.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/AttributeInformation.tsx index f3756d86e..9ebc420de 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/AttributeInformation.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/AttributeInformation.tsx @@ -14,6 +14,7 @@ import { usePostV2TerrafundNewSitePolygonUuidNewVersion } from "@/generated/apiComponents"; import { SitePolygon, SitePolygonsDataResponse } from "@/generated/apiSchemas"; +import Log from "@/utils/log"; const dropdownOptionsRestoration = [ { @@ -211,7 +212,7 @@ const AttributeInformation = ({ } ); } catch (error) { - console.error("Error creating polygon version:", error); + Log.error("Error creating polygon version:", error); } } const response = (await fetchGetV2SitePolygonUuid({ diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonStatus/StatusDisplay.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonStatus/StatusDisplay.tsx index 7e39d0070..667b976af 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonStatus/StatusDisplay.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonStatus/StatusDisplay.tsx @@ -7,6 +7,7 @@ import ModalConfirm from "@/components/extensive/Modal/ModalConfirm"; import { ModalId } from "@/components/extensive/Modal/ModalConst"; import { useModalContext } from "@/context/modal.provider"; import { useNotificationContext } from "@/context/notification.provider"; +import Log from "@/utils/log"; import { AuditLogEntity, AuditLogEntityEnum } from "../../../AuditLogTab/constants/types"; import { getRequestPathParam } from "../../../AuditLogTab/utils/util"; @@ -271,7 +272,7 @@ const StatusDisplay = ({ "The request encountered an issue, or the comment exceeds 255 characters." ); - console.error(e); + Log.error("The request encountered an issue", e); } finally { onFinallyRequest(); } @@ -307,7 +308,7 @@ const StatusDisplay = ({ "Error!", "The request encountered an issue, or the comment exceeds 255 characters." ); - console.error(e); + Log.error("Request encountered an issue", e); } finally { onFinallyRequest(); } diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx index 9878a345b..82d62b757 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx @@ -50,6 +50,7 @@ import { SitePolygonsLoadedDataResponse } from "@/generated/apiSchemas"; import { EntityName, FileType, UploadedFile } from "@/types/common"; +import Log from "@/utils/log"; import ModalIdentified from "../../extensive/Modal/ModalIdentified"; import AddDataButton from "./components/AddDataButton"; @@ -219,7 +220,7 @@ const PolygonReviewTab: FC = props => { linear: false }); } else { - console.error("Bounding box is not in the expected format"); + Log.error("Bounding box is not in the expected format"); } }; @@ -236,7 +237,7 @@ const PolygonReviewTab: FC = props => { } }) .catch(error => { - console.error("Error deleting polygon:", error); + Log.error("Error deleting polygon:", error); }); }; @@ -312,7 +313,7 @@ const PolygonReviewTab: FC = props => { hideLoader(); } catch (error) { if (error && typeof error === "object" && "message" in error) { - let errorMessage = error.message as string; + let errorMessage = (error as { message: string }).message; const parsedMessage = JSON.parse(errorMessage); if (parsedMessage && typeof parsedMessage === "object" && "message" in parsedMessage) { errorMessage = parsedMessage.message; @@ -375,7 +376,7 @@ const PolygonReviewTab: FC = props => { openNotification("success", "Success, Your Polygons were approved!", ""); refetch(); } catch (error) { - console.error(error); + Log.error("Polygon approval error", error); } }} /> diff --git a/src/admin/modules/form/components/CopyFormToOtherEnv.tsx b/src/admin/modules/form/components/CopyFormToOtherEnv.tsx index 18b86d72c..20e76da82 100644 --- a/src/admin/modules/form/components/CopyFormToOtherEnv.tsx +++ b/src/admin/modules/form/components/CopyFormToOtherEnv.tsx @@ -8,6 +8,7 @@ import { appendAdditionalFormQuestionFields } from "@/admin/modules/form/compone import RHFDropdown from "@/components/elements/Inputs/Dropdown/RHFDropdown"; import Input from "@/components/elements/Inputs/Input/Input"; import { fetchGetV2FormsLinkedFieldListing } from "@/generated/apiComponents"; +import Log from "@/utils/log"; const envOptions = [ { @@ -39,7 +40,7 @@ export const CopyFormToOtherEnv = () => { } }); const { register, handleSubmit, formState, getValues } = formHook; - console.log(getValues(), formState.errors); + Log.info(getValues(), formState.errors); const copyToDestinationEnv = async ({ env: baseUrl, title: formTitle, framework_key, ...body }: any) => { const linkedFieldsData: any = await fetchGetV2FormsLinkedFieldListing({}); @@ -50,7 +51,7 @@ export const CopyFormToOtherEnv = () => { }, body: JSON.stringify(body) }); - console.log(loginResp); + Log.debug("Login response", loginResp); if (loginResp.status !== 200) { return notify("wrong username password", { type: "error" }); diff --git a/src/components/elements/Accordion/Accordion.stories.tsx b/src/components/elements/Accordion/Accordion.stories.tsx index 83f3cc134..c5c5b441e 100644 --- a/src/components/elements/Accordion/Accordion.stories.tsx +++ b/src/components/elements/Accordion/Accordion.stories.tsx @@ -1,5 +1,7 @@ import { Meta, StoryObj } from "@storybook/react"; +import Log from "@/utils/log"; + import Accordion from "./Accordion"; const meta: Meta = { @@ -33,7 +35,7 @@ export const WithCTA: Story = { ...Default.args, ctaButtonProps: { text: "Edit", - onClick: console.log + onClick: () => Log.info("CTA clicked") } } }; diff --git a/src/components/elements/ImageGallery/ImageGalleryItem.tsx b/src/components/elements/ImageGallery/ImageGalleryItem.tsx index 74c82e115..e6465f546 100644 --- a/src/components/elements/ImageGallery/ImageGalleryItem.tsx +++ b/src/components/elements/ImageGallery/ImageGalleryItem.tsx @@ -14,6 +14,7 @@ import { useNotificationContext } from "@/context/notification.provider"; import { usePatchV2MediaProjectProjectMediaUuid, usePostV2ExportImage } from "@/generated/apiComponents"; import { useGetReadableEntityName } from "@/hooks/entity/useGetReadableEntityName"; import { SingularEntityName } from "@/types/common"; +import Log from "@/utils/log"; import ImageWithChildren from "../ImageWithChildren/ImageWithChildren"; import Menu from "../Menu/Menu"; @@ -97,7 +98,7 @@ const ImageGalleryItem: FC = ({ }); if (!response) { - console.error("No response received from the server."); + Log.error("No response received from the server."); openNotification("error", t("Error!"), t("No response received from the server.")); return; } @@ -116,7 +117,7 @@ const ImageGalleryItem: FC = ({ hideLoader(); openNotification("success", t("Success!"), t("Image downloaded successfully")); } catch (error) { - console.error("Download error:", error); + Log.error("Download error:", error); hideLoader(); } }; diff --git a/src/components/elements/Inputs/Dropdown/Dropdown.stories.tsx b/src/components/elements/Inputs/Dropdown/Dropdown.stories.tsx index 307a6b83f..12577f4ea 100644 --- a/src/components/elements/Inputs/Dropdown/Dropdown.stories.tsx +++ b/src/components/elements/Inputs/Dropdown/Dropdown.stories.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { OptionValue } from "@/types/common"; import { toArray } from "@/utils/array"; +import Log from "@/utils/log"; import Component, { DropdownProps as Props } from "./Dropdown"; @@ -52,7 +53,7 @@ export const SingleSelect: Story = { {...args} value={value} onChange={v => { - console.log(v); + Log.info("onChange", v); setValue(v); }} /> diff --git a/src/components/elements/Inputs/FileInput/RHFFileInput.tsx b/src/components/elements/Inputs/FileInput/RHFFileInput.tsx index 3797d0bb9..911ffb115 100644 --- a/src/components/elements/Inputs/FileInput/RHFFileInput.tsx +++ b/src/components/elements/Inputs/FileInput/RHFFileInput.tsx @@ -12,6 +12,7 @@ import { import { UploadedFile } from "@/types/common"; import { toArray } from "@/utils/array"; import { getErrorMessages } from "@/utils/errors"; +import Log from "@/utils/log"; import FileInput, { FileInputProps } from "./FileInput"; @@ -175,7 +176,7 @@ const RHFFileInput = ({ body.append("lng", location.longitude.toString()); } } catch (e) { - console.log(e); + Log.error("Failed to append geotagging information", e); } upload?.({ diff --git a/src/components/elements/Inputs/Select/Select.stories.tsx b/src/components/elements/Inputs/Select/Select.stories.tsx index f9c650963..913f14bae 100644 --- a/src/components/elements/Inputs/Select/Select.stories.tsx +++ b/src/components/elements/Inputs/Select/Select.stories.tsx @@ -1,5 +1,7 @@ import { Meta, StoryObj } from "@storybook/react"; +import Log from "@/utils/log"; + import Component from "./Select"; const meta: Meta = { @@ -15,7 +17,7 @@ export const Default: Story = { label: "Select label", description: "Select description", placeholder: "placeholder", - onChange: console.log, + onChange: Log.info, options: [ { title: "Option 1", diff --git a/src/components/elements/Inputs/SelectImage/SelectImage.stories.tsx b/src/components/elements/Inputs/SelectImage/SelectImage.stories.tsx index 5cd067b2c..4aa4fc103 100644 --- a/src/components/elements/Inputs/SelectImage/SelectImage.stories.tsx +++ b/src/components/elements/Inputs/SelectImage/SelectImage.stories.tsx @@ -1,5 +1,7 @@ import { Meta, StoryObj } from "@storybook/react"; +import Log from "@/utils/log"; + import Component from "./SelectImage"; const meta: Meta = { @@ -15,7 +17,7 @@ export const Default: Story = { label: "Select Image label", description: "Select Image description", placeholder: "placeholder", - onChange: console.log, + onChange: Log.info, options: [ { title: "Option 1", diff --git a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.stories.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.stories.tsx index 56d46691d..6e0df7760 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.stories.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.stories.tsx @@ -1,6 +1,8 @@ import { Meta, StoryObj } from "@storybook/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import Log from "@/utils/log"; + import Components from "./TreeSpeciesInput"; const meta: Meta = { @@ -32,8 +34,8 @@ export const Default: Story = { amount: 23 } ], - onChange: value => console.log("onChange", value), - clearErrors: () => console.log("clearErrors") + onChange: value => Log.info("onChange", value), + clearErrors: () => Log.info("clearErrors") } }; diff --git a/src/components/elements/Map-mapbox/Map.stories.tsx b/src/components/elements/Map-mapbox/Map.stories.tsx index 16a5e739c..ea8d2a0ce 100644 --- a/src/components/elements/Map-mapbox/Map.stories.tsx +++ b/src/components/elements/Map-mapbox/Map.stories.tsx @@ -6,6 +6,7 @@ import Toast from "@/components/elements/Toast/Toast"; import ModalRoot from "@/components/extensive/Modal/ModalRoot"; import ModalProvider from "@/context/modal.provider"; import ToastProvider from "@/context/toast.provider"; +import Log from "@/utils/log"; import Component from "./Map"; import sample from "./sample.json"; @@ -34,8 +35,8 @@ export const Default: Story = { ) ], args: { - onGeojsonChange: console.log, - onError: errors => console.log(JSON.stringify(errors)) + onGeojsonChange: Log.info, + onError: errors => Log.info(JSON.stringify(errors)) } }; diff --git a/src/components/elements/Map-mapbox/MapControls/CheckIndividualPolygonControl.tsx b/src/components/elements/Map-mapbox/MapControls/CheckIndividualPolygonControl.tsx index cd71b8256..93226e1aa 100644 --- a/src/components/elements/Map-mapbox/MapControls/CheckIndividualPolygonControl.tsx +++ b/src/components/elements/Map-mapbox/MapControls/CheckIndividualPolygonControl.tsx @@ -11,6 +11,7 @@ import { usePostV2TerrafundValidationPolygon } from "@/generated/apiComponents"; import { ClippedPolygonsResponse, SitePolygonsDataResponse } from "@/generated/apiSchemas"; +import Log from "@/utils/log"; import Button from "../../Button/Button"; @@ -77,7 +78,7 @@ const CheckIndividualPolygonControl = ({ viewRequestSuport }: { viewRequestSupor hideLoader(); }, onError: error => { - console.error("Error clipping polygons:", error); + Log.error("Error clipping polygons:", error); openNotification("error", t("Error! Could not fix polygons"), t("Please try again later.")); } }); diff --git a/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx b/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx index af2770eff..f831d3f1b 100644 --- a/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx +++ b/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx @@ -22,6 +22,7 @@ import { usePostV2TerrafundValidationSitePolygons } from "@/generated/apiComponents"; import { ClippedPolygonsResponse, SitePolygon } from "@/generated/apiSchemas"; +import Log from "@/utils/log"; import Button from "../../Button/Button"; import Text from "../../Text/Text"; @@ -120,7 +121,7 @@ const CheckPolygonControl = (props: CheckSitePolygonProps) => { closeModal(ModalId.FIX_POLYGONS); }, onError: error => { - console.error("Error clipping polygons:", error); + Log.error("Error clipping polygons:", error); displayNotification(t("An error occurred while fixing polygons. Please try again."), "error", t("Error")); } }); diff --git a/src/components/elements/Map-mapbox/MapLayers/GeoJsonLayer.tsx b/src/components/elements/Map-mapbox/MapLayers/GeoJsonLayer.tsx index c6f5872d9..8dab3f574 100644 --- a/src/components/elements/Map-mapbox/MapLayers/GeoJsonLayer.tsx +++ b/src/components/elements/Map-mapbox/MapLayers/GeoJsonLayer.tsx @@ -3,6 +3,7 @@ import { LngLatBoundsLike } from "mapbox-gl"; import { useEffect } from "react"; import { useMapContext } from "@/context/map.provider"; +import Log from "@/utils/log"; interface GeoJSONLayerProps { geojson: any; @@ -18,7 +19,7 @@ export const GeoJSONLayer = ({ geojson }: GeoJSONLayerProps) => { draw?.set(geojson); map.fitBounds(bbox(geojson) as LngLatBoundsLike, { padding: 50, animate: false }); } catch (e) { - console.log("invalid geoJSON", e); + Log.error("invalid geoJSON", e); } }, [draw, geojson, map]); diff --git a/src/components/elements/Map-mapbox/utils.ts b/src/components/elements/Map-mapbox/utils.ts index 2cb156c7b..a96c11f8e 100644 --- a/src/components/elements/Map-mapbox/utils.ts +++ b/src/components/elements/Map-mapbox/utils.ts @@ -17,6 +17,7 @@ import { useGetV2TerrafundPolygonBboxUuid } from "@/generated/apiComponents"; import { SitePolygon, SitePolygonsDataResponse } from "@/generated/apiSchemas"; +import Log from "@/utils/log"; import { MediaPopup } from "./components/MediaPopup"; import { BBox, Feature, FeatureCollection, GeoJsonProperties, Geometry } from "./GeoJSON"; @@ -92,7 +93,7 @@ const showPolygons = ( styles.forEach((style: LayerWithStyle, index: number) => { const layerName = `${name}-${index}`; if (!map.getLayer(layerName)) { - console.warn(`Layer ${layerName} does not exist.`); + Log.warn(`Layer ${layerName} does not exist.`); return; } const polygonStatus = style?.metadata?.polygonStatus; diff --git a/src/components/elements/MapPolygonPanel/AttributeInformation.tsx b/src/components/elements/MapPolygonPanel/AttributeInformation.tsx index b914412b7..05f893ce2 100644 --- a/src/components/elements/MapPolygonPanel/AttributeInformation.tsx +++ b/src/components/elements/MapPolygonPanel/AttributeInformation.tsx @@ -8,6 +8,7 @@ import { useMapAreaContext } from "@/context/mapArea.provider"; import { useNotificationContext } from "@/context/notification.provider"; import { useGetV2TerrafundPolygonUuid, usePutV2TerrafundSitePolygonUuid } from "@/generated/apiComponents"; import { SitePolygon } from "@/generated/apiSchemas"; +import Log from "@/utils/log"; import Text from "../Text/Text"; import { useTranslatedOptions } from "./hooks/useTranslatedOptions"; @@ -175,7 +176,7 @@ const AttributeInformation = ({ handleClose }: { handleClose: () => void }) => { } ); } catch (error) { - console.error("Error updating polygon data:", error); + Log.error("Error updating polygon data:", error); } } }; diff --git a/src/components/elements/MapPolygonPanel/ChecklistInformation.tsx b/src/components/elements/MapPolygonPanel/ChecklistInformation.tsx index 60d6277b1..b04ca404d 100644 --- a/src/components/elements/MapPolygonPanel/ChecklistInformation.tsx +++ b/src/components/elements/MapPolygonPanel/ChecklistInformation.tsx @@ -8,6 +8,7 @@ import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import { V2TerrafundCriteriaData } from "@/generated/apiSchemas"; import { isCompletedDataOrEstimatedArea } from "@/helpers/polygonValidation"; import { useMessageValidators } from "@/hooks/useMessageValidations"; +import Log from "@/utils/log"; import Text from "../Text/Text"; @@ -24,7 +25,7 @@ export const validationLabels: any = { }; function useRenderCounter() { const ref = useRef(0); - console.log(`Render count: ${++ref.current}`); + Log.debug(`Render count: ${++ref.current}`); } const ChecklistInformation = ({ criteriaData }: { criteriaData: V2TerrafundCriteriaData }) => { useRenderCounter(); diff --git a/src/components/elements/MapPolygonPanel/MapPolygonPanel.stories.tsx b/src/components/elements/MapPolygonPanel/MapPolygonPanel.stories.tsx index aaff2349f..517286631 100644 --- a/src/components/elements/MapPolygonPanel/MapPolygonPanel.stories.tsx +++ b/src/components/elements/MapPolygonPanel/MapPolygonPanel.stories.tsx @@ -1,6 +1,8 @@ import { Meta, StoryObj } from "@storybook/react"; import { useState } from "react"; +import Log from "@/utils/log"; + import Component from "./MapPolygonPanel"; const meta: Meta = { @@ -95,7 +97,7 @@ export const Default: Story = { }, args: { title: "Project Sites", - onSelectItem: console.log + onSelectItem: Log.info } }; @@ -113,6 +115,6 @@ export const OpenPolygonCheck: Story = { }, args: { title: "Project Sites", - onSelectItem: console.log + onSelectItem: Log.info } }; diff --git a/src/components/elements/MapSidePanel/MapSidePanel.stories.tsx b/src/components/elements/MapSidePanel/MapSidePanel.stories.tsx index 843f91075..1cac4cadf 100644 --- a/src/components/elements/MapSidePanel/MapSidePanel.stories.tsx +++ b/src/components/elements/MapSidePanel/MapSidePanel.stories.tsx @@ -1,6 +1,8 @@ import { Meta, StoryObj } from "@storybook/react"; import { useState } from "react"; +import Log from "@/utils/log"; + import Component from "./MapSidePanel"; const meta: Meta = { @@ -35,7 +37,7 @@ const items = [ title: "Puerto Princesa Subterranean River National Park Forest Corridor", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: console.log, + setClickedButton: Log.info, onCheckboxChange: () => {}, refContainer: null, type: "sites", @@ -46,7 +48,7 @@ const items = [ title: "A medium sized project site to see how it looks with 2 lines", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: console.log, + setClickedButton: Log.info, onCheckboxChange: () => {}, refContainer: null, type: "sites", @@ -57,7 +59,7 @@ const items = [ title: "A shorter project site", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: console.log, + setClickedButton: Log.info, onCheckboxChange: () => {}, refContainer: null, type: "sites", @@ -69,7 +71,7 @@ const items = [ "Very long name A medium sized project site to see how it looks with 2 lines A medium sized project site to see how it looks with 2 lines A medium sized project site to see how it looks with 2 lines", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: console.log, + setClickedButton: Log.info, onCheckboxChange: () => {}, refContainer: null, type: "sites", @@ -80,7 +82,7 @@ const items = [ title: "A shorter project site", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: console.log, + setClickedButton: Log.info, onCheckboxChange: () => {}, refContainer: null, type: "sites", @@ -91,7 +93,7 @@ const items = [ title: "A shorter project site", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: console.log, + setClickedButton: Log.info, onCheckboxChange: () => {}, refContainer: null, type: "sites", diff --git a/src/components/extensive/EntityMapAndGalleryCard/EntityMapAndGalleryCard.tsx b/src/components/extensive/EntityMapAndGalleryCard/EntityMapAndGalleryCard.tsx index 257306753..1a9eb2846 100644 --- a/src/components/extensive/EntityMapAndGalleryCard/EntityMapAndGalleryCard.tsx +++ b/src/components/extensive/EntityMapAndGalleryCard/EntityMapAndGalleryCard.tsx @@ -27,6 +27,7 @@ import { } from "@/generated/apiComponents"; import { useGetImagesGeoJSON } from "@/hooks/useImageGeoJSON"; import { EntityName, FileType, UploadedFile } from "@/types/common"; +import Log from "@/utils/log"; import ModalAdd from "../Modal/ModalAdd"; import { ModalId } from "../Modal/ModalConst"; @@ -196,7 +197,7 @@ const EntityMapAndGalleryCard = ({ closeModal(ModalId.UPLOAD_IMAGES); }) .catch(error => { - console.error("Error uploading files:", error); + Log.error("Error uploading files:", error); hideLoader(); }); } diff --git a/src/components/extensive/Modal/FormModal.stories.tsx b/src/components/extensive/Modal/FormModal.stories.tsx index a3379004c..b3538b6fe 100644 --- a/src/components/extensive/Modal/FormModal.stories.tsx +++ b/src/components/extensive/Modal/FormModal.stories.tsx @@ -2,6 +2,7 @@ import { Meta, StoryObj } from "@storybook/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { getDisturbanceTableFields } from "@/components/elements/Inputs/DataTable/RHFDisturbanceTable"; +import Log from "@/utils/log"; import Component, { FormModalProps as Props } from "./FormModal"; @@ -25,6 +26,6 @@ export const Default: Story = { args: { title: "Add new disturbance", fields: getDisturbanceTableFields({ hasIntensity: true, hasExtent: true }), - onSubmit: console.log + onSubmit: Log.info } }; diff --git a/src/components/extensive/Modal/Modal.stories.tsx b/src/components/extensive/Modal/Modal.stories.tsx index 70bc34abd..e6056db8d 100644 --- a/src/components/extensive/Modal/Modal.stories.tsx +++ b/src/components/extensive/Modal/Modal.stories.tsx @@ -1,5 +1,7 @@ import { Meta, StoryObj } from "@storybook/react"; +import Log from "@/utils/log"; + import { IconNames } from "../Icon/Icon"; import Component, { ModalProps as Props } from "./Modal"; @@ -27,11 +29,11 @@ export const Default: Story = { }, primaryButtonProps: { children: "Close and continue later", - onClick: console.log + onClick: () => Log.info("close clicked") }, secondaryButtonProps: { children: "Cancel", - onClick: console.log + onClick: () => Log.info("secondary clicked") } } }; diff --git a/src/components/extensive/Modal/ModalImageDetails.tsx b/src/components/extensive/Modal/ModalImageDetails.tsx index fdee88092..090db1ee0 100644 --- a/src/components/extensive/Modal/ModalImageDetails.tsx +++ b/src/components/extensive/Modal/ModalImageDetails.tsx @@ -14,6 +14,7 @@ import Modal from "@/components/extensive/Modal/Modal"; import { useModalContext } from "@/context/modal.provider"; import { useNotificationContext } from "@/context/notification.provider"; import { usePatchV2MediaProjectProjectMediaUuid, usePatchV2MediaUuid } from "@/generated/apiComponents"; +import Log from "@/utils/log"; import Icon, { IconNames } from "../Icon/Icon"; import PageBreadcrumbs from "../PageElements/Breadcrumbs/PageBreadcrumbs"; @@ -121,7 +122,7 @@ const ModalImageDetails: FC = ({ onClose?.(); } catch (error) { openNotification("error", t("Error"), t("Failed to update image details")); - console.error("Failed to update image details:", error); + Log.error("Failed to update image details:", error); } }; diff --git a/src/components/extensive/Modal/ModalWithClose.stories.tsx b/src/components/extensive/Modal/ModalWithClose.stories.tsx index d8fed45e4..f93f542c4 100644 --- a/src/components/extensive/Modal/ModalWithClose.stories.tsx +++ b/src/components/extensive/Modal/ModalWithClose.stories.tsx @@ -1,5 +1,7 @@ import { Meta, StoryObj } from "@storybook/react"; +import Log from "@/utils/log"; + import { IconNames } from "../Icon/Icon"; import { ModalProps as Props } from "./Modal"; import Component from "./ModalWithClose"; @@ -28,11 +30,11 @@ export const Default: Story = { }, primaryButtonProps: { children: "Close and continue later", - onClick: console.log + onClick: () => Log.info("close clicked") }, secondaryButtonProps: { children: "Cancel", - onClick: console.log + onClick: () => Log.info("secondary clicked") } } }; diff --git a/src/components/extensive/Modal/ModalWithLogo.stories.tsx b/src/components/extensive/Modal/ModalWithLogo.stories.tsx index a55823498..127845067 100644 --- a/src/components/extensive/Modal/ModalWithLogo.stories.tsx +++ b/src/components/extensive/Modal/ModalWithLogo.stories.tsx @@ -1,6 +1,8 @@ import { Meta, StoryObj } from "@storybook/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import Log from "@/utils/log"; + import { IconNames } from "../Icon/Icon"; import Component, { ModalWithLogoProps as Props } from "./ModalWithLogo"; @@ -32,11 +34,11 @@ export const Default: Story = { }, primaryButtonProps: { children: "Close and continue later", - onClick: console.log + onClick: () => Log.info("close clicked") }, secondaryButtonProps: { children: "Cancel", - onClick: console.log + onClick: () => Log.info("secondary clicked") } } }; diff --git a/src/components/extensive/Pagination/PerPageSelector.stories.tsx b/src/components/extensive/Pagination/PerPageSelector.stories.tsx index 1c69a6f0a..e13a69391 100644 --- a/src/components/extensive/Pagination/PerPageSelector.stories.tsx +++ b/src/components/extensive/Pagination/PerPageSelector.stories.tsx @@ -1,5 +1,7 @@ import { Meta, StoryObj } from "@storybook/react"; +import Log from "@/utils/log"; + import Component from "./PerPageSelector"; const meta: Meta = { @@ -18,5 +20,5 @@ export const Default: Story = { ) ], - args: { options: [5, 10, 15, 20, 50], onChange: console.log, defaultValue: 5 } + args: { options: [5, 10, 15, 20, 50], onChange: Log.info, defaultValue: 5 } }; diff --git a/src/components/extensive/WizardForm/WizardForm.stories.tsx b/src/components/extensive/WizardForm/WizardForm.stories.tsx index ac87c9ea5..d351a3f2b 100644 --- a/src/components/extensive/WizardForm/WizardForm.stories.tsx +++ b/src/components/extensive/WizardForm/WizardForm.stories.tsx @@ -10,6 +10,7 @@ import { } from "@/components/elements/Inputs/DataTable/RHFFundingTypeDataTable"; import { getCountriesOptions } from "@/constants/options/countries"; import { FileType } from "@/types/common"; +import Log from "@/utils/log"; import Component, { WizardFormProps as Props } from "."; import { FieldType, FormStepSchema } from "./types"; @@ -308,8 +309,8 @@ export const CreateForm: Story = { ), args: { steps: getSteps(false), - onStepChange: console.log, - onChange: console.log, + onStepChange: Log.info, + onChange: Log.info, nextButtonText: "Save and Continue", submitButtonText: "Submit", hideBackButton: false, @@ -326,8 +327,8 @@ export const EditForm = { ...CreateForm, args: { steps: getSteps(true), - onStepChange: console.log, - onChange: console.log, + onStepChange: Log.info, + onChange: Log.info, nextButtonText: "Save", submitButtonText: "Save", hideBackButton: true, diff --git a/src/components/extensive/WizardForm/index.tsx b/src/components/extensive/WizardForm/index.tsx index 03c6d9c0a..1e50e7a3f 100644 --- a/src/components/extensive/WizardForm/index.tsx +++ b/src/components/extensive/WizardForm/index.tsx @@ -12,6 +12,7 @@ import { FormStepSchema } from "@/components/extensive/WizardForm/types"; import { useModalContext } from "@/context/modal.provider"; import { ErrorWrapper } from "@/generated/apiFetcher"; import { useDebounce } from "@/hooks/useDebounce"; +import Log from "@/utils/log"; import { ModalId } from "../Modal/ModalConst"; import { FormFooter } from "./FormFooter"; @@ -89,11 +90,9 @@ function WizardForm(props: WizardFormProps) { const formHasError = Object.values(formHook.formState.errors || {}).filter(item => !!item).length > 0; - if (process.env.NODE_ENV === "development") { - console.debug("Form Steps", props.steps); - console.debug("Form Values", formHook.watch()); - console.debug("Form Errors", formHook.formState.errors); - } + Log.debug("Form Steps", props.steps); + Log.debug("Form Values", formHook.watch()); + Log.debug("Form Errors", formHook.formState.errors); const onChange = useDebounce(() => !formHasError && props.onChange?.(formHook.getValues())); diff --git a/src/constants/options/frameworks.ts b/src/constants/options/frameworks.ts index 31f0c4812..271a9c810 100644 --- a/src/constants/options/frameworks.ts +++ b/src/constants/options/frameworks.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { GetListParams } from "react-admin"; import { reportingFrameworkDataProvider } from "@/admin/apiProvider/dataProviders/reportingFrameworkDataProvider"; +import Log from "@/utils/log"; async function getFrameworkChoices() { const params: GetListParams = { @@ -29,7 +30,7 @@ export function useFrameworkChoices() { try { setFrameworkChoices(await getFrameworkChoices()); } catch (error) { - console.error("Error fetching framework choices", error); + Log.error("Error fetching framework choices", error); } }; diff --git a/src/context/mapArea.provider.tsx b/src/context/mapArea.provider.tsx index a7a6aef4f..2ecf57ebe 100644 --- a/src/context/mapArea.provider.tsx +++ b/src/context/mapArea.provider.tsx @@ -2,6 +2,7 @@ import React, { createContext, ReactNode, useContext, useState } from "react"; import { fetchGetV2DashboardViewProjectUuid } from "@/generated/apiComponents"; import { SitePolygon } from "@/generated/apiSchemas"; +import Log from "@/utils/log"; type MapAreaType = { isMonitoring: boolean; @@ -129,7 +130,7 @@ export const MapAreaProvider: React.FC<{ children: ReactNode }> = ({ children }) }); setIsMonitoring(isMonitoringPartner?.allowed ?? false); } catch (error) { - console.error("Failed to check if monitoring partner:", error); + Log.error("Failed to check if monitoring partner:", error); setIsMonitoring(false); } }; diff --git a/src/generated/apiFetcher.ts b/src/generated/apiFetcher.ts index bcc311b57..ddd7c77b0 100644 --- a/src/generated/apiFetcher.ts +++ b/src/generated/apiFetcher.ts @@ -1,6 +1,7 @@ import { AdminTokenStorageKey } from "../admin/apiProvider/utils/token"; import { ApiContext } from "./apiContext"; import FormData from "form-data"; +import Log from "@/utils/log"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL + "/api"; @@ -86,9 +87,7 @@ export async function apiFetch< ...(await response.json()) }; } catch (e) { - if (process.env.NODE_ENV === "development") { - console.log("apiFetch", e); - } + Log.error("v1/2 API Fetch error", e); error = { statusCode: -1 }; @@ -104,9 +103,7 @@ export async function apiFetch< return (await response.blob()) as unknown as TData; } } catch (e) { - if (process.env.NODE_ENV === "development") { - console.log("apiFetch", e); - } + Log.error("v1/2 API Fetch error", e); error = { statusCode: response?.status || -1, //@ts-ignore diff --git a/src/generated/v3/utils.ts b/src/generated/v3/utils.ts index 87b3f8ea8..25905b80f 100644 --- a/src/generated/v3/utils.ts +++ b/src/generated/v3/utils.ts @@ -1,4 +1,5 @@ import ApiSlice, { ApiDataStore, isErrorState, isInProgress, Method, PendingErrorState } from "@/store/apiSlice"; +import Log from "@/utils/log"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; @@ -76,7 +77,7 @@ export async function dispatchRequest(url: string, requestInit: R ApiSlice.fetchSucceeded({ ...actionPayload, response: responsePayload }); } } catch (e) { - console.error("Unexpected API fetch failure", e); + Log.error("Unexpected API fetch failure", e); const message = e instanceof Error ? `Network error (${e.message})` : "Network error"; ApiSlice.fetchFailed({ ...actionPayload, error: { statusCode: -1, message } }); } diff --git a/src/hooks/useGetCustomFormSteps/useGetCustomFormSteps.stories.tsx b/src/hooks/useGetCustomFormSteps/useGetCustomFormSteps.stories.tsx index b7ac916d8..05d4dec17 100644 --- a/src/hooks/useGetCustomFormSteps/useGetCustomFormSteps.stories.tsx +++ b/src/hooks/useGetCustomFormSteps/useGetCustomFormSteps.stories.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import WizardForm, { WizardFormProps } from "@/components/extensive/WizardForm"; import { FormRead } from "@/generated/apiSchemas"; import { getCustomFormSteps } from "@/helpers/customForms"; +import Log from "@/utils/log"; import formSchema from "./formSchema.json"; @@ -27,8 +28,8 @@ export const WithGetFormStepHook: Story = { ), args: { steps: getCustomFormSteps(formSchema as FormRead, (t: any) => t), - onStepChange: console.log, - onChange: console.log, + onStepChange: Log.info, + onChange: Log.info, nextButtonText: "Save and Continue", submitButtonText: "Submit", hideBackButton: false, diff --git a/src/hooks/useMessageValidations.ts b/src/hooks/useMessageValidations.ts index 1bacd8602..f88dc552a 100644 --- a/src/hooks/useMessageValidations.ts +++ b/src/hooks/useMessageValidations.ts @@ -1,6 +1,8 @@ import { useT } from "@transifex/react"; import { useMemo } from "react"; +import Log from "@/utils/log"; + interface IntersectionInfo { intersectSmaller: boolean; percentage: number; @@ -51,7 +53,7 @@ export const useMessageValidators = () => { }); }); } catch (error) { - console.error(error); + Log.error("Failed to get intersection messages", error); return [t("Error parsing extra info.")]; } }, diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index cc27465b7..67f11a477 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -21,6 +21,7 @@ import RouteHistoryProvider from "@/context/routeHistory.provider"; import ToastProvider from "@/context/toast.provider"; import { getServerSideTranslations, setClientSideTranslations } from "@/i18n"; import StoreProvider from "@/store/StoreProvider"; +import Log from "@/utils/log"; import setupYup from "@/yup.locale"; const CookieBanner = dynamic(() => import("@/components/extensive/CookieBanner/CookieBanner"), { @@ -87,7 +88,7 @@ _App.getInitialProps = async (context: AppContext) => { try { translationsData = await getServerSideTranslations(context.ctx); } catch (err) { - console.log("Failed to get Serverside Transifex", err); + Log.warn("Failed to get Serverside Transifex", err); } return { ...ctx, props: { ...translationsData }, authToken: cookies.accessToken }; }; diff --git a/src/pages/applications/components/ApplicationHeader.tsx b/src/pages/applications/components/ApplicationHeader.tsx index 59d5e2b66..b9c92fa39 100644 --- a/src/pages/applications/components/ApplicationHeader.tsx +++ b/src/pages/applications/components/ApplicationHeader.tsx @@ -4,6 +4,7 @@ import { When } from "react-if"; import Button from "@/components/elements/Button/Button"; import PageHeader from "@/components/extensive/PageElements/Header/PageHeader"; import { fetchGetV2ApplicationsUUIDExport } from "@/generated/apiComponents"; +import Log from "@/utils/log"; import { downloadFileBlob } from "@/utils/network"; interface ApplicationHeaderProps { @@ -25,7 +26,7 @@ const ApplicationHeader = ({ name, status, uuid }: ApplicationHeaderProps) => { if (!res) return; return downloadFileBlob(res, "Application.csv"); } catch (err) { - console.log(err); + Log.error("Failed to fetch applications exports", err); } }; diff --git a/src/pages/auth/verify/email/[token].page.tsx b/src/pages/auth/verify/email/[token].page.tsx index 0ff27a388..27ecaf781 100644 --- a/src/pages/auth/verify/email/[token].page.tsx +++ b/src/pages/auth/verify/email/[token].page.tsx @@ -9,6 +9,7 @@ import { IconNames } from "@/components/extensive/Icon/Icon"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; import { fetchPatchV2AuthVerify } from "@/generated/apiComponents"; +import Log from "@/utils/log"; const VerifyEmail: NextPage> = () => { const t = useT(); @@ -40,7 +41,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { try { await fetchPatchV2AuthVerify({ body: { token } }); } catch (e) { - console.log(e); + Log.error("Failed to verify auth", e); options = { redirect: { permanent: false, diff --git a/src/pages/debug/index.page.tsx b/src/pages/debug/index.page.tsx index ce3cc342d..91a8b728d 100644 --- a/src/pages/debug/index.page.tsx +++ b/src/pages/debug/index.page.tsx @@ -8,6 +8,7 @@ import PageBody from "@/components/extensive/PageElements/Body/PageBody"; import PageCard from "@/components/extensive/PageElements/Card/PageCard"; import PageHeader from "@/components/extensive/PageElements/Header/PageHeader"; import PageSection from "@/components/extensive/PageElements/Section/PageSection"; +import Log from "@/utils/log"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL + "/api"; @@ -27,7 +28,7 @@ const DebugPage = () => { }; } catch (e) { if (process.env.NODE_ENV === "development") { - console.log("apiFetch", e); + Log.error("apiFetch", e); } error = { statusCode: -1 @@ -38,7 +39,7 @@ const DebugPage = () => { } } catch (e) { if (process.env.NODE_ENV === "development") { - console.log("apiFetch", e); + Log.error("apiFetch", e); } error = { statusCode: response?.status || -1, diff --git a/src/pages/site/[uuid]/tabs/Overview.tsx b/src/pages/site/[uuid]/tabs/Overview.tsx index 5e3ad683a..e1638ead6 100644 --- a/src/pages/site/[uuid]/tabs/Overview.tsx +++ b/src/pages/site/[uuid]/tabs/Overview.tsx @@ -40,6 +40,7 @@ import { SitePolygonsDataResponse, SitePolygonsLoadedDataResponse } from "@/gene import { getEntityDetailPageLink } from "@/helpers/entity"; import { statusActionsMap } from "@/hooks/AuditStatus/useAuditLogActions"; import { FileType, UploadedFile } from "@/types/common"; +import Log from "@/utils/log"; import SiteArea from "../components/SiteArea"; @@ -164,7 +165,7 @@ const SiteOverviewTab = ({ site, refetch: refetchEntity }: SiteOverviewTabProps) setSubmitPolygonLoaded(false); } catch (error) { if (error && typeof error === "object" && "message" in error) { - let errorMessage = error.message as string; + let errorMessage = (error as { message: string }).message; const parsedMessage = JSON.parse(errorMessage); if (parsedMessage && typeof parsedMessage === "object" && "message" in parsedMessage) { errorMessage = parsedMessage.message; @@ -348,7 +349,7 @@ const SiteOverviewTab = ({ site, refetch: refetchEntity }: SiteOverviewTabProps) setShouldRefetchPolygonData(true); openNotification("success", t("Success! Your polygons were submitted.")); } catch (error) { - console.log(error); + Log.error("Failed to fetch polygon statuses", error); } }} /> diff --git a/src/utils/geojson.ts b/src/utils/geojson.ts index d4060f25d..8cf2035d8 100644 --- a/src/utils/geojson.ts +++ b/src/utils/geojson.ts @@ -16,7 +16,7 @@ import normalize from "@mapbox/geojson-normalize"; * { type: 'Feature', geometry: { type: 'Point', coordinates: [0, 1] }, properties: {} } * ]); * - * console.log(JSON.stringify(mergedGeoJSON)); + * Log.debug(JSON.stringify(mergedGeoJSON)); */ export const merge = (inputs: any) => { var output: any = { diff --git a/src/utils/log.ts b/src/utils/log.ts new file mode 100644 index 000000000..8375c89ef --- /dev/null +++ b/src/utils/log.ts @@ -0,0 +1,37 @@ +import { captureException, captureMessage, SeverityLevel, withScope } from "@sentry/nextjs"; + +const IS_PROD = process.env.NODE_ENV === "production"; + +const sentryLog = (level: SeverityLevel, message: any, optionalParams: any[]) => { + const error = optionalParams.find(param => param instanceof Error); + + withScope(scope => { + if (error == null) { + scope.setExtras({ optionalParams }); + captureMessage(message, level); + } else { + scope.setExtras({ message, optionalParams }); + captureException(error); + } + }); +}; + +export default class Log { + static debug(message: any, ...optionalParams: any[]) { + if (!IS_PROD) console.debug(message, ...optionalParams); + } + + static info(message: any, ...optionalParams: any[]) { + if (!IS_PROD) console.info(message, ...optionalParams); + } + + static warn(message: any, ...optionalParams: any[]) { + console.warn(message, ...optionalParams); + sentryLog("warning", message, optionalParams); + } + + static error(message: any, ...optionalParams: any[]) { + console.error(message, ...optionalParams); + sentryLog("error", message, optionalParams); + } +} diff --git a/src/utils/network.ts b/src/utils/network.ts index e93a9c366..d200ab381 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -2,6 +2,8 @@ import { QueryClient } from "@tanstack/react-query"; import { GetServerSidePropsContext } from "next"; import nookies from "nookies"; +import Log from "@/utils/log"; + /** * Prefetch queries in ServerSideProps * @param queryClient Tanstack QueryClient @@ -32,7 +34,7 @@ export const downloadFile = async (fileUrl: string) => { const blob = await res.blob(); downloadFileBlob(blob, fileName); } catch (err) { - console.log(err); + Log.error("Failed to download file", fileUrl, err); } }; @@ -53,6 +55,6 @@ export const downloadFileBlob = async (blob: Blob, fileName: string) => { // Clean up and remove the link link?.parentNode?.removeChild(link); } catch (err) { - console.log(err); + Log.error("Failed to download blob", fileName, err); } }; From a8c6d7b3780cf709583f3c47cc8185e12f472092 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 23 Sep 2024 13:37:45 -0700 Subject: [PATCH 010/102] [TM-1272] Unit tests for useConnection. --- src/connections/Login.ts | 2 + src/hooks/useConnection.test.ts | 79 +++++++++++++++++++++++++++++++++ src/store/store.ts | 2 +- 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useConnection.test.ts diff --git a/src/connections/Login.ts b/src/connections/Login.ts index 16ddeface..fbaaa1fc1 100644 --- a/src/connections/Login.ts +++ b/src/connections/Login.ts @@ -16,6 +16,8 @@ type LoginConnection = { export const login = (emailAddress: string, password: string) => authLogin({ body: { emailAddress, password } }); export const logout = () => { removeAccessToken(); + // When we log out, remove all cached API resources so that when we log in again, these resources + // are freshly fetched from the BE. ApiSlice.clearApiCache(); }; diff --git a/src/hooks/useConnection.test.ts b/src/hooks/useConnection.test.ts new file mode 100644 index 000000000..f60ea6c32 --- /dev/null +++ b/src/hooks/useConnection.test.ts @@ -0,0 +1,79 @@ +import { renderHook } from "@testing-library/react"; +import { act } from "react-dom/test-utils"; +import { createSelector } from "reselect"; + +import { LoginResponse } from "@/generated/v3/userService/userServiceSchemas"; +import { useConnection } from "@/hooks/useConnection"; +import ApiSlice, { ApiDataStore, JsonApiResource } from "@/store/apiSlice"; +import { makeStore } from "@/store/store"; +import { Connection } from "@/types/connection"; + +describe("Test useConnection hook", () => { + beforeEach(() => { + ApiSlice.store = makeStore(); + }); + + test("isLoaded", () => { + const load = jest.fn(); + let connectionLoaded = false; + const connection = { + selector: () => ({ connectionLoaded }), + load, + isLoaded: ({ connectionLoaded }) => connectionLoaded + } as Connection<{ connectionLoaded: boolean }>; + let rendered = renderHook(() => useConnection(connection)); + + expect(rendered.result.current[0]).toBe(false); + expect(load).toHaveBeenCalled(); + + load.mockReset(); + connectionLoaded = true; + rendered = renderHook(() => useConnection(connection)); + expect(rendered.result.current[0]).toBe(true); + expect(load).toHaveBeenCalled(); + }); + + test("selector efficiency", () => { + const selector = jest.fn(({ logins }: ApiDataStore) => logins); + const payloadCreator = jest.fn(logins => { + const values = Object.values(logins); + return { login: values.length < 1 ? null : values[0] }; + }); + const connection = { + selector: createSelector([selector], payloadCreator) + } as Connection<{ login: LoginResponse }>; + + const { result, rerender } = renderHook(() => useConnection(connection)); + rerender(); + + expect(result.current[1]).toStrictEqual({ login: null }); + // The rerender doesn't cause an additional call to either function because the input (the + // redux store) didn't change. + expect(selector).toHaveBeenCalledTimes(1); + expect(payloadCreator).toHaveBeenCalledTimes(1); + + const token = "asdfasdfasdf"; + const data = { type: "logins", id: "1", token } as JsonApiResource; + act(() => { + ApiSlice.fetchSucceeded({ url: "/foo", method: "POST", response: { data } }); + }); + + // The store has changed so the selector gets called again, and the selector's result has + // changed so the payload creator gets called again, and returns the new Login response that + // was saved in the store. + expect(result.current[1]).toStrictEqual({ login: data }); + expect(selector).toHaveBeenCalledTimes(2); + expect(payloadCreator).toHaveBeenCalledTimes(2); + + rerender(); + // The store didn't change, so neither function gets called. + expect(selector).toHaveBeenCalledTimes(2); + expect(payloadCreator).toHaveBeenCalledTimes(2); + + ApiSlice.fetchStarting({ url: "/bar", method: "POST" }); + // The store has changed, so the selector gets called again, but the selector's result is + // the same so the payload creator does not get called again, and returns its memoized result. + expect(selector).toHaveBeenCalledTimes(3); + expect(payloadCreator).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/store/store.ts b/src/store/store.ts index 5b8da3850..1cc3c081c 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -9,7 +9,7 @@ export const makeStore = (authToken?: string) => { api: apiSlice.reducer }, middleware: getDefaultMiddleware => { - if (process.env.NODE_ENV === "production") { + if (process.env.NODE_ENV === "production" || process.env.NODE_ENV === "test") { return getDefaultMiddleware().prepend(authListenerMiddleware.middleware); } else { return getDefaultMiddleware().prepend(authListenerMiddleware.middleware).concat(logger); From 28679a0050f140dc60e1afb3d469a9049b25e1a1 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 23 Sep 2024 13:57:57 -0700 Subject: [PATCH 011/102] [TM-1272] Document the steps needed when adding a new service or resource. --- README.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9900b4d37..914d6bbdc 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ To generate Api types/queries/fetchers/hooks, this repo uses: #### Usage -In order to generate from the api (whenever there is some backend endpoint/type change) please use this command: +In order to generate from the v1/2 api (whenever there is some backend endpoint/type change) please use this command: ``` yarn generate:api @@ -35,6 +35,30 @@ We can customize the `baseUrl` of where we are fetching from by changing the `co This is super useful if we want to globally set some headers for each request (such as Authorization header). It exposes a component hook so we can use other hooks (such as Auth hook or context) to get the logged in token for example and inject it in the global request context. +##### v3 API +The V3 API has a different API layer, but the generation is similar: +``` +yarn generate:services +``` + +When adding a new **service** app to the v3 API, a few steps are needed to integrate it: +* In `openapi-codegen.config.ts`, add the new service name to the `SERVICES` array (e.g. `foo-service`). +* This will generate a new target, which needs to be added to `package.json`: + * Under scripts, add `"generate:fooService": "npm run generate:fooService"` + * Under the `"generate:services"` script, add the new service: `"generate:services": "npm run generate:userService && npm run generate:fooService` +* After running `yarn generate:fooService` the first time, open the generated `fooServiceFetcher.ts` and + modify it to match `userServiceFetcher.ts`. + * This file does not get regenerated after the first time, and so it can utilize the same utilities + for interfacing with the redux API layer / connection system that the other v3 services use. + +When adding a new **resource** to the v3 API, a couple of steps are needed to integrate it: +* The resource needs to be specified in shape of the redux API store. In `apiSlice.ts`, add the new + resource plural name (the `type` returned in the API responses) to the store by adding it to the + `RESOURCES` const. This will make sure it's listed in the type of the ApiStore so that resources that match that type are seamlessly folded into the store cache structure. +* The shape of the resource should be specified by the auto-generated API. This type needs to be + added to the `ApiResource` type in `apiSlice.ts`. This allows us to have strongly typed results + coming from the redux APi store. + ## Translation ([Transifex Native SDK](https://developers.transifex.com/docs/native)). Transifex native sdk provides a simple solution for internationalization. From 1b9db6e2cd90e05de86be301717a63ec8b918894 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 23 Sep 2024 14:03:21 -0700 Subject: [PATCH 012/102] [TM-1272] Catch the generator up with some changes that happened to the redux layer. --- openapi-codegen.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 78ed5ce64..37b7f3798 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -151,8 +151,8 @@ const generatePendingPredicates = async (context: Context, config: ConfigBase) = await context.writeFile( filename + ".ts", printNodes([ - createNamedImport(["ApiDataStore"], "@/types/connection"), - createNamedImport(["isFetching", "apiFetchFailed"], `../utils`), + createNamedImport(["isFetching", "fetchFailed"], `../utils`), + createNamedImport(["ApiDataStore"], "@/store/apiSlice"), ...(componentImports.length == 0 ? [] : [createNamedImport(componentImports, `./${formatFilename(filenamePrefix + "-components")}`)]), @@ -190,7 +190,7 @@ const createPredicateNodes = ({ ); nodes.push( - ...["isFetching", "apiFetchFailed"].map(fnName => + ...["isFetching", "fetchFailed"].map(fnName => f.createVariableStatement( [f.createModifier(ts.SyntaxKind.ExportKeyword)], f.createVariableDeclarationList( From 19342a91d538b08b4ad2c075899a5d6862489656 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 23 Sep 2024 14:24:29 -0700 Subject: [PATCH 013/102] [TM-1272] Add a link to the new Connections documentation. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 914d6bbdc..617be089b 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,11 @@ When adding a new **resource** to the v3 API, a couple of steps are needed to in added to the `ApiResource` type in `apiSlice.ts`. This allows us to have strongly typed results coming from the redux APi store. +### Connections +Connections are a **declarative** way for components to get access to the data from the cached API +layer that they need. This system is under development, and the current documentation about it is +[available in Confluence](https://gfw.atlassian.net/wiki/spaces/TerraMatch/pages/1423147024/Connections) + ## Translation ([Transifex Native SDK](https://developers.transifex.com/docs/native)). Transifex native sdk provides a simple solution for internationalization. From a044bdd50704ae674adedd0f5e5b89ac50716763 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 23 Sep 2024 22:25:35 -0700 Subject: [PATCH 014/102] [TM-1272] Specs for the useConnection hook. --- ...nection.test.ts => useConnection.test.tsx} | 19 ++++++++++--------- src/hooks/useConnection.ts | 8 +++++--- 2 files changed, 15 insertions(+), 12 deletions(-) rename src/hooks/{useConnection.test.ts => useConnection.test.tsx} (84%) diff --git a/src/hooks/useConnection.test.ts b/src/hooks/useConnection.test.tsx similarity index 84% rename from src/hooks/useConnection.test.ts rename to src/hooks/useConnection.test.tsx index f60ea6c32..ce7e92b60 100644 --- a/src/hooks/useConnection.test.ts +++ b/src/hooks/useConnection.test.tsx @@ -1,18 +1,17 @@ import { renderHook } from "@testing-library/react"; +import { ReactNode } from "react"; import { act } from "react-dom/test-utils"; import { createSelector } from "reselect"; import { LoginResponse } from "@/generated/v3/userService/userServiceSchemas"; import { useConnection } from "@/hooks/useConnection"; import ApiSlice, { ApiDataStore, JsonApiResource } from "@/store/apiSlice"; -import { makeStore } from "@/store/store"; +import StoreProvider from "@/store/StoreProvider"; import { Connection } from "@/types/connection"; -describe("Test useConnection hook", () => { - beforeEach(() => { - ApiSlice.store = makeStore(); - }); +const StoreWrapper = ({ children }: { children: ReactNode }) => {children}; +describe("Test useConnection hook", () => { test("isLoaded", () => { const load = jest.fn(); let connectionLoaded = false; @@ -21,14 +20,14 @@ describe("Test useConnection hook", () => { load, isLoaded: ({ connectionLoaded }) => connectionLoaded } as Connection<{ connectionLoaded: boolean }>; - let rendered = renderHook(() => useConnection(connection)); + let rendered = renderHook(() => useConnection(connection), { wrapper: StoreWrapper }); expect(rendered.result.current[0]).toBe(false); expect(load).toHaveBeenCalled(); load.mockReset(); connectionLoaded = true; - rendered = renderHook(() => useConnection(connection)); + rendered = renderHook(() => useConnection(connection), { wrapper: StoreWrapper }); expect(rendered.result.current[0]).toBe(true); expect(load).toHaveBeenCalled(); }); @@ -43,7 +42,7 @@ describe("Test useConnection hook", () => { selector: createSelector([selector], payloadCreator) } as Connection<{ login: LoginResponse }>; - const { result, rerender } = renderHook(() => useConnection(connection)); + const { result, rerender } = renderHook(() => useConnection(connection), { wrapper: StoreWrapper }); rerender(); expect(result.current[1]).toStrictEqual({ login: null }); @@ -70,7 +69,9 @@ describe("Test useConnection hook", () => { expect(selector).toHaveBeenCalledTimes(2); expect(payloadCreator).toHaveBeenCalledTimes(2); - ApiSlice.fetchStarting({ url: "/bar", method: "POST" }); + act(() => { + ApiSlice.fetchStarting({ url: "/bar", method: "POST" }); + }); // The store has changed, so the selector gets called again, but the selector's result is // the same so the payload creator does not get called again, and returns its memoized result. expect(selector).toHaveBeenCalledTimes(3); diff --git a/src/hooks/useConnection.ts b/src/hooks/useConnection.ts index e03552671..b1b6152c5 100644 --- a/src/hooks/useConnection.ts +++ b/src/hooks/useConnection.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; +import { useStore } from "react-redux"; -import ApiSlice from "@/store/apiSlice"; +import { AppStore } from "@/store/store"; import { Connected, Connection, OptionalProps } from "@/types/connection"; /** @@ -15,9 +16,10 @@ export function useConnection = {} ): Connected { const { selector, isLoaded, load } = connection; + const store = useStore(); const getConnected = useCallback(() => { - const connected = selector(ApiSlice.store.getState().api, props); + const connected = selector(store.getState().api, props); const loadingDone = isLoaded == null || isLoaded(connected, props); return { loadingDone, connected }; }, [isLoaded, props, selector]); @@ -40,7 +42,7 @@ export function useConnection Date: Mon, 23 Sep 2024 22:41:14 -0700 Subject: [PATCH 015/102] [TM-1272] Get storybook and test:ci fully working. --- .storybook/preview.js | 16 ++++++++++++++++ .../generic/Navbar/Navbar.stories.tsx | 19 +++++++++++-------- src/hooks/useConnection.ts | 2 +- src/store/StoreProvider.tsx | 3 ++- src/store/store.ts | 12 +++++++----- 5 files changed, 37 insertions(+), 15 deletions(-) diff --git a/.storybook/preview.js b/.storybook/preview.js index a5e2d6fba..f9f8b88a2 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,5 +1,6 @@ import "src/styles/globals.css"; import * as NextImage from "next/image"; +import StoreProvider from "../src/store/StoreProvider"; export const parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, @@ -24,3 +25,18 @@ Object.defineProperty(NextImage, "default", { /> ) }); + +export const decorators = [ + (Story, options) => { + const { parameters } = options; + + let storeProviderProps = {}; + if (parameters.storeProviderProps != null) { + storeProviderProps = parameters.storeProviderProps; + } + + return + + ; + }, +]; diff --git a/src/components/generic/Navbar/Navbar.stories.tsx b/src/components/generic/Navbar/Navbar.stories.tsx index 8a6659507..4e9dbaeb5 100644 --- a/src/components/generic/Navbar/Navbar.stories.tsx +++ b/src/components/generic/Navbar/Navbar.stories.tsx @@ -13,21 +13,24 @@ type Story = StoryObj; const client = new QueryClient(); export const LoggedIn: Story = { + parameters: { + storeProviderProps: { authToken: "fakeauthtoken" } + }, decorators: [ Story => ( ) - ], - args: { - isLoggedIn: true - } + ] }; export const LoggedOut: Story = { - ...LoggedIn, - args: { - isLoggedIn: false - } + decorators: [ + Story => ( + + + + ) + ] }; diff --git a/src/hooks/useConnection.ts b/src/hooks/useConnection.ts index b1b6152c5..8b31f294b 100644 --- a/src/hooks/useConnection.ts +++ b/src/hooks/useConnection.ts @@ -22,7 +22,7 @@ export function useConnection { const { loadingDone, connected } = getConnected(); diff --git a/src/store/StoreProvider.tsx b/src/store/StoreProvider.tsx index dbd55e95f..ad6f3e84d 100644 --- a/src/store/StoreProvider.tsx +++ b/src/store/StoreProvider.tsx @@ -1,6 +1,7 @@ "use client"; import { useRef } from "react"; import { Provider } from "react-redux"; +import { Store } from "redux"; import { AppStore, makeStore } from "./store"; @@ -11,7 +12,7 @@ export default function StoreProvider({ authToken?: string; children: React.ReactNode; }) { - const storeRef = useRef(); + const storeRef = useRef>(); if (!storeRef.current) { // Create the store instance the first time this renders storeRef.current = makeStore(authToken); diff --git a/src/store/store.ts b/src/store/store.ts index 1cc3c081c..b9f78bf74 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,9 +1,14 @@ import { configureStore } from "@reduxjs/toolkit"; +import { Store } from "redux"; import { logger } from "redux-logger"; -import ApiSlice, { apiSlice, authListenerMiddleware } from "@/store/apiSlice"; +import ApiSlice, { ApiDataStore, apiSlice, authListenerMiddleware } from "@/store/apiSlice"; -export const makeStore = (authToken?: string) => { +export type AppStore = { + api: ApiDataStore; +}; + +export const makeStore = (authToken?: string): Store => { const store = configureStore({ reducer: { api: apiSlice.reducer @@ -25,6 +30,3 @@ export const makeStore = (authToken?: string) => { return store; }; - -// Infer the type of makeStore -export type AppStore = ReturnType; From 08f6b231c6018a08c24fb91a6aca76f1907d58da Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 26 Sep 2024 21:13:42 -0700 Subject: [PATCH 016/102] [TM-1312] Adapt to the more robust JSON:API shape the v3 BE is sending now. --- src/admin/apiProvider/authProvider.ts | 27 +---- src/connections/Login.ts | 5 +- .../v3/userService/userServiceComponents.ts | 112 +++++++++++++++++- .../v3/userService/userServicePredicates.ts | 7 ++ .../v3/userService/userServiceSchemas.ts | 48 ++++++-- src/store/apiSlice.ts | 50 ++++++-- 6 files changed, 199 insertions(+), 50 deletions(-) diff --git a/src/admin/apiProvider/authProvider.ts b/src/admin/apiProvider/authProvider.ts index 90922d014..db9b62df2 100644 --- a/src/admin/apiProvider/authProvider.ts +++ b/src/admin/apiProvider/authProvider.ts @@ -1,13 +1,13 @@ import { AuthProvider } from "react-admin"; -import { fetchGetAuthLogout, fetchGetAuthMe } from "@/generated/apiComponents"; +import { fetchGetAuthLogout } from "@/generated/apiComponents"; import Log from "@/utils/log"; import { AdminTokenStorageKey, removeAccessToken } from "./utils/token"; export const authProvider: AuthProvider = { // send username and password to the auth server and get back credentials - login: async params => { + login: async () => { Log.error("Admin app does not support direct login"); }, @@ -17,7 +17,7 @@ export const authProvider: AuthProvider = { }, // when the user navigates, make sure that their credentials are still valid - checkAuth: async params => { + checkAuth: async () => { const token = localStorage.getItem(AdminTokenStorageKey); if (!token) return Promise.reject(); @@ -51,27 +51,6 @@ export const authProvider: AuthProvider = { }); }, - // get the user's profile - getIdentity: async () => { - const token = localStorage.getItem(AdminTokenStorageKey); - if (!token) return Promise.reject(); - - return new Promise((resolve, reject) => { - fetchGetAuthMe({}) - .then(response => { - //@ts-ignore - const userData = response.data; - resolve({ - ...userData, - fullName: `${userData.first_name} ${userData.last_name}` - }); - }) - .catch(() => { - reject(); - }); - }); - }, - // get the user permissions (optional) getPermissions: () => { return Promise.resolve(); diff --git a/src/connections/Login.ts b/src/connections/Login.ts index fbaaa1fc1..8684a09a3 100644 --- a/src/connections/Login.ts +++ b/src/connections/Login.ts @@ -21,10 +21,7 @@ export const logout = () => { ApiSlice.clearApiCache(); }; -const selectFirstLogin = (state: ApiDataStore) => { - const values = Object.values(state.logins); - return values.length < 1 ? null : values[0]; -}; +const selectFirstLogin = (state: ApiDataStore) => Object.values(state.logins)?.[0]?.attributes; export const loginConnection: Connection = { selector: createSelector( diff --git a/src/generated/v3/userService/userServiceComponents.ts b/src/generated/v3/userService/userServiceComponents.ts index cd96b66da..38fcb11c5 100644 --- a/src/generated/v3/userService/userServiceComponents.ts +++ b/src/generated/v3/userService/userServiceComponents.ts @@ -18,11 +18,25 @@ export type AuthLoginError = Fetcher.ErrorWrapper<{ * @example Unauthorized */ message: string; + /** + * @example Unauthorized + */ + error?: string; }; }>; export type AuthLoginResponse = { - data?: Schemas.LoginResponse; + data?: { + /** + * @example logins + */ + type?: string; + /** + * @pattern ^\d{5}$ + */ + id?: string; + attributes?: Schemas.LoginDto; + }; }; export type AuthLoginVariables = { @@ -39,3 +53,99 @@ export const authLogin = (variables: AuthLoginVariables, signal?: AbortSignal) = ...variables, signal }); + +export type UsersFindPathParams = { + id: string; +}; + +export type UsersFindError = Fetcher.ErrorWrapper< + | { + status: 401; + payload: { + /** + * @example 401 + */ + statusCode: number; + /** + * @example Unauthorized + */ + message: string; + /** + * @example Unauthorized + */ + error?: string; + }; + } + | { + status: 404; + payload: { + /** + * @example 404 + */ + statusCode: number; + /** + * @example Not Found + */ + message: string; + /** + * @example Not Found + */ + error?: string; + }; + } +>; + +export type UsersFindResponse = { + data?: { + /** + * @example users + */ + type?: string; + /** + * @format uuid + */ + id?: string; + attributes?: Schemas.UserDto; + relationships?: { + org?: { + /** + * @example organisations + */ + type?: string; + /** + * @format uuid + */ + id?: string; + meta?: { + userStatus?: "approved" | "requested" | "rejected"; + }; + }; + }; + }; + included?: { + /** + * @example organisations + */ + type?: string; + /** + * @format uuid + */ + id?: string; + attributes?: Schemas.OrganisationDto; + }[]; +}; + +export type UsersFindVariables = { + pathParams: UsersFindPathParams; +}; + +/** + * Fetch a user by ID, or with the 'me' identifier + */ +export const usersFind = (variables: UsersFindVariables, signal?: AbortSignal) => + userServiceFetch({ + url: "/users/v3/users/{id}", + method: "get", + ...variables, + signal + }); diff --git a/src/generated/v3/userService/userServicePredicates.ts b/src/generated/v3/userService/userServicePredicates.ts index a79ea180b..e20c5f4de 100644 --- a/src/generated/v3/userService/userServicePredicates.ts +++ b/src/generated/v3/userService/userServicePredicates.ts @@ -1,8 +1,15 @@ import { isFetching, fetchFailed } from "../utils"; import { ApiDataStore } from "@/store/apiSlice"; +import { UsersFindPathParams, UsersFindVariables } from "./userServiceComponents"; export const authLoginIsFetching = (state: ApiDataStore) => isFetching<{}, {}>({ state, url: "/auth/v3/logins", method: "post" }); export const authLoginFetchFailed = (state: ApiDataStore) => fetchFailed<{}, {}>({ state, url: "/auth/v3/logins", method: "post" }); + +export const usersFindIsFetching = (state: ApiDataStore, variables: UsersFindVariables) => + isFetching<{}, UsersFindPathParams>({ state, url: "/users/v3/users/{id}", method: "get", ...variables }); + +export const usersFindFetchFailed = (state: ApiDataStore, variables: UsersFindVariables) => + fetchFailed<{}, UsersFindPathParams>({ state, url: "/users/v3/users/{id}", method: "get", ...variables }); diff --git a/src/generated/v3/userService/userServiceSchemas.ts b/src/generated/v3/userService/userServiceSchemas.ts index c95b87215..59c257689 100644 --- a/src/generated/v3/userService/userServiceSchemas.ts +++ b/src/generated/v3/userService/userServiceSchemas.ts @@ -3,17 +3,7 @@ * * @version 1.0 */ -export type LoginResponse = { - /** - * @example logins - */ - type: string; - /** - * The ID of the user associated with this login - * - * @example 1234 - */ - id: string; +export type LoginDto = { /** * JWT token for use in future authenticated requests to the API. * @@ -26,3 +16,39 @@ export type LoginRequest = { emailAddress: string; password: string; }; + +export type UserFramework = { + /** + * @example TerraFund Landscapes + */ + name: string; + /** + * @example terrafund-landscapes + */ + slug: string; +}; + +export type UserDto = { + firstName: string; + lastName: string; + /** + * Currently just calculated by appending lastName to firstName. + */ + fullName: string; + primaryRole: string; + /** + * @example person@foocorp.net + */ + emailAddress: string; + /** + * @format date-time + */ + emailAddressVerifiedAt: string; + locale: string; + frameworks: UserFramework[]; +}; + +export type OrganisationDto = { + status: "draft" | "pending" | "approved" | "rejected"; + name: string; +}; diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index f11b36c83..f932f453e 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -3,7 +3,7 @@ import { isArray } from "lodash"; import { Store } from "redux"; import { setAccessToken } from "@/admin/apiProvider/utils/token"; -import { LoginResponse } from "@/generated/v3/userService/userServiceSchemas"; +import { LoginDto, OrganisationDto, UserDto } from "@/generated/v3/userService/userServiceSchemas"; export type PendingErrorState = { statusCode: number; @@ -25,26 +25,55 @@ export type ApiPendingStore = { [key in Method]: Record; }; +type AttributeValue = string | number | boolean; +type Attributes = { + [key: string]: AttributeValue | Attributes; +}; + +type Relationship = { + type: string; + id: string; + meta?: Attributes; +}; + +type StoreResource = { + attributes: AttributeType; + relationships?: { + [key: string]: Relationship | Relationship[]; + }; +}; + +type StoreResourceMap = Record>; + // The list of potential resource types. IMPORTANT: When a new resource type is integrated, it must // be added to this list. -export const RESOURCES = ["logins"] as const; +export const RESOURCES = ["logins", "organisations", "users"] as const; + +type ApiResources = { + logins: StoreResourceMap; + organisations: StoreResourceMap; + users: StoreResourceMap; +}; export type JsonApiResource = { type: (typeof RESOURCES)[number]; id: string; + attributes: Attributes; + relationships?: Relationship | Relationship[]; }; export type JsonApiResponse = { data: JsonApiResource[] | JsonApiResource; -}; - -type ApiResources = { - logins: Record; + included?: JsonApiResource[]; }; export type ApiDataStore = ApiResources & { meta: { + /** Stores the state of in-flight and failed requests */ pending: ApiPendingStore; + + /** Is snatched and stored by middleware when a users/me request completes. */ + meUserId?: string; }; }; @@ -96,7 +125,8 @@ export const apiSlice = createSlice({ // The data resource type is expected to match what is declared above in ApiDataStore, but // there isn't a way to enforce that with TS against this dynamic data structure, so we // use the dreaded any. - state[resource.type][resource.id] = resource as any; + const { type, id, ...rest } = resource; + state[type][id] = rest as StoreResource; } }, @@ -116,7 +146,7 @@ export const apiSlice = createSlice({ // We only ever expect there to be at most one Login in the store, and we never inspect the ID // so we can safely fake a login into the store when we have an authToken already set in a // cookie on app bootup. - state.logins["1"] = { id: "id", type: "logins", token: authToken }; + state.logins["1"] = { attributes: { token: authToken } }; } } }); @@ -134,8 +164,8 @@ authListenerMiddleware.startListening({ const { url, method, response } = action.payload; if (!url.endsWith("auth/v3/logins") || method !== "POST") return; - const { data } = response as { data: LoginResponse }; - setAccessToken(data.token); + const { token } = (response.data as JsonApiResource).attributes as LoginDto; + setAccessToken(token); } }); From 28f8f241d7d29bd3f07df12513451012ac22e2ae Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 26 Sep 2024 22:45:17 -0700 Subject: [PATCH 017/102] [TM-1312] Implemented a functional myUserConnection. --- openapi-codegen.config.ts | 99 ++++++++++--------- src/admin/apiProvider/authProvider.ts | 6 +- src/admin/apiProvider/utils/token.ts | 14 +-- .../modules/form/components/CloneForm.tsx | 6 +- src/connections/Login.ts | 2 +- src/connections/User.ts | 33 +++++++ src/generated/apiFetcher.ts | 8 +- .../v3/userService/userServiceFetcher.ts | 63 +----------- .../v3/userService/userServicePredicates.ts | 16 +-- src/generated/v3/utils.ts | 80 +++++++++++++-- src/hooks/useUserData.ts | 4 + src/store/apiSlice.ts | 41 +++++--- src/store/store.ts | 2 +- 13 files changed, 223 insertions(+), 151 deletions(-) create mode 100644 src/connections/User.ts diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 37b7f3798..2d97a2d6a 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -180,18 +180,65 @@ const createPredicateNodes = ({ }) => { const nodes: ts.Node[] = []; - const stateTypeDeclaration = f.createParameterDeclaration( + const storeTypeDeclaration = f.createParameterDeclaration( undefined, undefined, - f.createIdentifier("state"), + f.createIdentifier("store"), undefined, f.createTypeReferenceNode("ApiDataStore"), undefined ); nodes.push( - ...["isFetching", "fetchFailed"].map(fnName => - f.createVariableStatement( + ...["isFetching", "fetchFailed"].map(fnName => { + const callBaseSelector = f.createCallExpression( + f.createIdentifier(fnName), + [queryParamsType, pathParamsType], + [ + f.createObjectLiteralExpression( + [ + f.createShorthandPropertyAssignment("store"), + f.createPropertyAssignment(f.createIdentifier("url"), f.createStringLiteral(camelizedPathParams(url))), + f.createPropertyAssignment(f.createIdentifier("method"), f.createStringLiteral(verb)), + ...(variablesType.kind !== ts.SyntaxKind.VoidKeyword + ? [f.createSpreadAssignment(f.createIdentifier("variables"))] + : []) + ], + false + ) + ] + ); + + let selector = f.createArrowFunction( + undefined, + undefined, + [storeTypeDeclaration], + undefined, + f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + callBaseSelector + ); + + if (variablesType.kind !== ts.SyntaxKind.VoidKeyword) { + selector = f.createArrowFunction( + undefined, + undefined, + [ + f.createParameterDeclaration( + undefined, + undefined, + f.createIdentifier("variables"), + undefined, + variablesType, + undefined + ) + ], + undefined, + f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + selector + ); + } + + return f.createVariableStatement( [f.createModifier(ts.SyntaxKind.ExportKeyword)], f.createVariableDeclarationList( [ @@ -199,51 +246,13 @@ const createPredicateNodes = ({ f.createIdentifier(`${name}${_.upperFirst(fnName)}`), undefined, undefined, - f.createArrowFunction( - undefined, - undefined, - variablesType.kind !== ts.SyntaxKind.VoidKeyword - ? [ - stateTypeDeclaration, - f.createParameterDeclaration( - undefined, - undefined, - f.createIdentifier("variables"), - undefined, - variablesType, - undefined - ) - ] - : [stateTypeDeclaration], - undefined, - f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - f.createCallExpression( - f.createIdentifier(fnName), - [queryParamsType, pathParamsType], - [ - f.createObjectLiteralExpression( - [ - f.createShorthandPropertyAssignment("state"), - f.createPropertyAssignment( - f.createIdentifier("url"), - f.createStringLiteral(camelizedPathParams(url)) - ), - f.createPropertyAssignment(f.createIdentifier("method"), f.createStringLiteral(verb)), - ...(variablesType.kind !== ts.SyntaxKind.VoidKeyword - ? [f.createSpreadAssignment(f.createIdentifier("variables"))] - : []) - ], - false - ) - ] - ) - ) + selector ) ], ts.NodeFlags.Const ) - ) - ) + ); + }) ); return nodes; diff --git a/src/admin/apiProvider/authProvider.ts b/src/admin/apiProvider/authProvider.ts index db9b62df2..d9625c8f2 100644 --- a/src/admin/apiProvider/authProvider.ts +++ b/src/admin/apiProvider/authProvider.ts @@ -3,7 +3,7 @@ import { AuthProvider } from "react-admin"; import { fetchGetAuthLogout } from "@/generated/apiComponents"; import Log from "@/utils/log"; -import { AdminTokenStorageKey, removeAccessToken } from "./utils/token"; +import { getAccessToken, removeAccessToken } from "./utils/token"; export const authProvider: AuthProvider = { // send username and password to the auth server and get back credentials @@ -18,7 +18,7 @@ export const authProvider: AuthProvider = { // when the user navigates, make sure that their credentials are still valid checkAuth: async () => { - const token = localStorage.getItem(AdminTokenStorageKey); + const token = getAccessToken(); if (!token) return Promise.reject(); // TODO (TM-1312) Once we have a connection for the users/me object, we can check the cached @@ -36,7 +36,7 @@ export const authProvider: AuthProvider = { }, // remove local credentials and notify the auth server that the user logged out logout: async () => { - const token = localStorage.getItem(AdminTokenStorageKey); + const token = getAccessToken(); if (!token) return Promise.resolve(); return new Promise(resolve => { diff --git a/src/admin/apiProvider/utils/token.ts b/src/admin/apiProvider/utils/token.ts index 80c1931d2..7867699ee 100644 --- a/src/admin/apiProvider/utils/token.ts +++ b/src/admin/apiProvider/utils/token.ts @@ -1,12 +1,14 @@ import { destroyCookie, setCookie } from "nookies"; -export const AdminTokenStorageKey = "access_token"; -export const AdminCookieStorageKey = "accessToken"; +const TOKEN_STORAGE_KEY = "access_token"; +const COOKIE_STORAGE_KEY = "accessToken"; const MiddlewareCacheKey = "middlewareCache"; +export const getAccessToken = () => localStorage.getItem(TOKEN_STORAGE_KEY); + export const setAccessToken = (token: string) => { - localStorage.setItem(AdminTokenStorageKey, token); - setCookie(null, AdminCookieStorageKey, token, { + localStorage.setItem(TOKEN_STORAGE_KEY, token); + setCookie(null, COOKIE_STORAGE_KEY, token, { maxAge: 60 * 60 * 12, // 12 hours secure: process.env.NODE_ENV !== "development", path: "/" @@ -14,8 +16,8 @@ export const setAccessToken = (token: string) => { }; export const removeAccessToken = () => { - localStorage.removeItem(AdminTokenStorageKey); - destroyCookie(null, AdminCookieStorageKey, { + localStorage.removeItem(TOKEN_STORAGE_KEY); + destroyCookie(null, COOKIE_STORAGE_KEY, { path: "/" }); destroyCookie(null, MiddlewareCacheKey, { diff --git a/src/admin/modules/form/components/CloneForm.tsx b/src/admin/modules/form/components/CloneForm.tsx index 104750f2c..e91755ea1 100644 --- a/src/admin/modules/form/components/CloneForm.tsx +++ b/src/admin/modules/form/components/CloneForm.tsx @@ -4,7 +4,7 @@ import { useNotify, useRecordContext } from "react-admin"; import { useForm } from "react-hook-form"; import { normalizeFormCreatePayload } from "@/admin/apiProvider/dataNormalizers/formDataNormalizer"; -import { AdminTokenStorageKey } from "@/admin/apiProvider/utils/token"; +import { getAccessToken } from "@/admin/apiProvider/utils/token"; import { appendAdditionalFormQuestionFields } from "@/admin/modules/form/components/FormBuilder/QuestionArrayInput"; import Input from "@/components/elements/Inputs/Input/Input"; import { fetchGetV2FormsLinkedFieldListing } from "@/generated/apiComponents"; @@ -12,7 +12,7 @@ import { fetchGetV2FormsLinkedFieldListing } from "@/generated/apiComponents"; export const CloneForm = () => { const record: any = useRecordContext(); const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; - const token = localStorage.getItem(AdminTokenStorageKey); + const token = getAccessToken(); const [open, setOpen] = useState(false); const notify = useNotify(); const formHook = useForm({ @@ -90,7 +90,7 @@ export const CloneForm = () => { - diff --git a/src/connections/Login.ts b/src/connections/Login.ts index 8684a09a3..558020461 100644 --- a/src/connections/Login.ts +++ b/src/connections/Login.ts @@ -21,7 +21,7 @@ export const logout = () => { ApiSlice.clearApiCache(); }; -const selectFirstLogin = (state: ApiDataStore) => Object.values(state.logins)?.[0]?.attributes; +const selectFirstLogin = (store: ApiDataStore) => Object.values(store.logins)?.[0]?.attributes; export const loginConnection: Connection = { selector: createSelector( diff --git a/src/connections/User.ts b/src/connections/User.ts new file mode 100644 index 000000000..a9479d591 --- /dev/null +++ b/src/connections/User.ts @@ -0,0 +1,33 @@ +import { createSelector } from "reselect"; + +import { usersFind, UsersFindVariables } from "@/generated/v3/userService/userServiceComponents"; +import { usersFindFetchFailed } from "@/generated/v3/userService/userServicePredicates"; +import { UserDto } from "@/generated/v3/userService/userServiceSchemas"; +import { ApiDataStore, Relationships } from "@/store/apiSlice"; +import { Connection } from "@/types/connection"; + +type UserConnection = { + user?: UserDto; + userRelationships?: Relationships; + userLoadFailed: boolean; +}; + +const selectMeId = (store: ApiDataStore) => store.meta.meUserId; +const selectUsers = (store: ApiDataStore) => store.users; +const selectMe = createSelector([selectMeId, selectUsers], (meId, users) => (meId == null ? undefined : users?.[meId])); + +const FIND_ME: UsersFindVariables = { pathParams: { id: "me" } }; + +export const myUserConnection: Connection = { + load: ({ user }) => { + if (user == null) usersFind(FIND_ME); + }, + + isLoaded: ({ user, userLoadFailed }) => userLoadFailed || user != null, + + selector: createSelector([selectMe, usersFindFetchFailed(FIND_ME)], (resource, userLoadFailure) => ({ + user: resource?.attributes, + userRelationships: resource?.relationships, + userLoadFailed: userLoadFailure != null + })) +}; diff --git a/src/generated/apiFetcher.ts b/src/generated/apiFetcher.ts index ddd7c77b0..2b3b621f2 100644 --- a/src/generated/apiFetcher.ts +++ b/src/generated/apiFetcher.ts @@ -1,4 +1,4 @@ -import { AdminTokenStorageKey } from "../admin/apiProvider/utils/token"; +import { getAccessToken } from "../admin/apiProvider/utils/token"; import { ApiContext } from "./apiContext"; import FormData from "form-data"; import Log from "@/utils/log"; @@ -56,10 +56,10 @@ export async function apiFetch< ...headers }; - const adminToken = typeof window !== "undefined" && localStorage.getItem(AdminTokenStorageKey); + const accessToken = typeof window !== "undefined" && getAccessToken(); - if (!requestHeaders?.Authorization && adminToken) { - requestHeaders.Authorization = `Bearer ${adminToken}`; + if (!requestHeaders?.Authorization && accessToken) { + requestHeaders.Authorization = `Bearer ${accessToken}`; } /** diff --git a/src/generated/v3/userService/userServiceFetcher.ts b/src/generated/v3/userService/userServiceFetcher.ts index 46bfad6e4..add02a4ee 100644 --- a/src/generated/v3/userService/userServiceFetcher.ts +++ b/src/generated/v3/userService/userServiceFetcher.ts @@ -1,65 +1,6 @@ -import { dispatchRequest, resolveUrl } from "@/generated/v3/utils"; - // This type is imported in the auto generated `userServiceComponents` file, so it needs to be // exported from this file. export type { ErrorWrapper } from "../utils"; -export type UserServiceFetcherExtraProps = { - /** - * You can add some extra props to your generated fetchers. - * - * Note: You need to re-gen after adding the first property to - * have the `UserServiceFetcherExtraProps` injected in `UserServiceComponents.ts` - **/ -}; - -export type UserServiceFetcherOptions = { - url: string; - method: string; - body?: TBody; - headers?: THeaders; - queryParams?: TQueryParams; - pathParams?: TPathParams; - signal?: AbortSignal; -} & UserServiceFetcherExtraProps; - -export function userServiceFetch< - TData, - TError, - TBody extends {} | FormData | undefined | null, - THeaders extends {}, - TQueryParams extends {}, - TPathParams extends {} ->({ - url, - method, - body, - headers, - pathParams, - queryParams, - signal -}: UserServiceFetcherOptions) { - const requestHeaders: HeadersInit = { - "Content-Type": "application/json", - ...headers - }; - - /** - * As the fetch API is being used, when multipart/form-data is specified - * the Content-Type header must be deleted so that the browser can set - * the correct boundary. - * https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object - */ - if (requestHeaders["Content-Type"].toLowerCase().includes("multipart/form-data")) { - delete requestHeaders["Content-Type"]; - } - - // The promise is ignored on purpose. Further progress of the request is tracked through - // redux. - dispatchRequest(resolveUrl(url, queryParams, pathParams), { - signal, - method: method.toUpperCase(), - body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined, - headers: requestHeaders - }); -} +// The serviceFetch method is the shared fetch method for all service fetchers. +export { serviceFetch as userServiceFetch } from "../utils"; diff --git a/src/generated/v3/userService/userServicePredicates.ts b/src/generated/v3/userService/userServicePredicates.ts index e20c5f4de..16771c4b5 100644 --- a/src/generated/v3/userService/userServicePredicates.ts +++ b/src/generated/v3/userService/userServicePredicates.ts @@ -2,14 +2,14 @@ import { isFetching, fetchFailed } from "../utils"; import { ApiDataStore } from "@/store/apiSlice"; import { UsersFindPathParams, UsersFindVariables } from "./userServiceComponents"; -export const authLoginIsFetching = (state: ApiDataStore) => - isFetching<{}, {}>({ state, url: "/auth/v3/logins", method: "post" }); +export const authLoginIsFetching = (store: ApiDataStore) => + isFetching<{}, {}>({ store, url: "/auth/v3/logins", method: "post" }); -export const authLoginFetchFailed = (state: ApiDataStore) => - fetchFailed<{}, {}>({ state, url: "/auth/v3/logins", method: "post" }); +export const authLoginFetchFailed = (store: ApiDataStore) => + fetchFailed<{}, {}>({ store, url: "/auth/v3/logins", method: "post" }); -export const usersFindIsFetching = (state: ApiDataStore, variables: UsersFindVariables) => - isFetching<{}, UsersFindPathParams>({ state, url: "/users/v3/users/{id}", method: "get", ...variables }); +export const usersFindIsFetching = (variables: UsersFindVariables) => (store: ApiDataStore) => + isFetching<{}, UsersFindPathParams>({ store, url: "/users/v3/users/{id}", method: "get", ...variables }); -export const usersFindFetchFailed = (state: ApiDataStore, variables: UsersFindVariables) => - fetchFailed<{}, UsersFindPathParams>({ state, url: "/users/v3/users/{id}", method: "get", ...variables }); +export const usersFindFetchFailed = (variables: UsersFindVariables) => (store: ApiDataStore) => + fetchFailed<{}, UsersFindPathParams>({ store, url: "/users/v3/users/{id}", method: "get", ...variables }); diff --git a/src/generated/v3/utils.ts b/src/generated/v3/utils.ts index 25905b80f..4d8450871 100644 --- a/src/generated/v3/utils.ts +++ b/src/generated/v3/utils.ts @@ -1,12 +1,13 @@ import ApiSlice, { ApiDataStore, isErrorState, isInProgress, Method, PendingErrorState } from "@/store/apiSlice"; import Log from "@/utils/log"; +import { getAccessToken } from "@/admin/apiProvider/utils/token"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; export type ErrorWrapper = TError | { statusCode: -1; message: string }; type SelectorOptions = { - state: ApiDataStore; + store: ApiDataStore; url: string; method: string; queryParams?: TQueryParams; @@ -29,30 +30,32 @@ export const resolveUrl = ( }; export function isFetching({ - state, + store, url, method, pathParams, queryParams }: SelectorOptions): boolean { const fullUrl = resolveUrl(url, queryParams, pathParams); - const pending = state.meta.pending[method.toUpperCase() as Method][fullUrl]; + const pending = store.meta.pending[method.toUpperCase() as Method][fullUrl]; return isInProgress(pending); } export function fetchFailed({ - state, + store, url, method, pathParams, queryParams }: SelectorOptions): PendingErrorState | null { const fullUrl = resolveUrl(url, queryParams, pathParams); - const pending = state.meta.pending[method.toUpperCase() as Method][fullUrl]; + const pending = store.meta.pending[method.toUpperCase() as Method][fullUrl]; return isErrorState(pending) ? pending : null; } -export async function dispatchRequest(url: string, requestInit: RequestInit) { +const isPending = (method: Method, fullUrl: string) => ApiSlice.apiDataStore.meta.pending[method][fullUrl] != null; + +async function dispatchRequest(url: string, requestInit: RequestInit) { const actionPayload = { url, method: requestInit.method as Method }; ApiSlice.fetchStarting(actionPayload); @@ -82,3 +85,68 @@ export async function dispatchRequest(url: string, requestInit: R ApiSlice.fetchFailed({ ...actionPayload, error: { statusCode: -1, message } }); } } + +export type ServiceFetcherOptions = { + url: string; + method: string; + body?: TBody; + headers?: THeaders; + queryParams?: TQueryParams; + pathParams?: TPathParams; + signal?: AbortSignal; +}; + +export function serviceFetch< + TData, + TError, + TBody extends {} | FormData | undefined | null, + THeaders extends {}, + TQueryParams extends {}, + TPathParams extends {} +>({ + url, + method: methodString, + body, + headers, + pathParams, + queryParams, + signal +}: ServiceFetcherOptions) { + const fullUrl = resolveUrl(url, queryParams, pathParams); + const method = methodString.toUpperCase() as Method; + if (isPending(method, fullUrl)) { + // Ignore requests to issue an API request that is in progress or has failed without a cache + // clear. + return; + } + + const requestHeaders: HeadersInit = { + "Content-Type": "application/json", + ...headers + }; + + const accessToken = typeof window === "undefined" ? null : getAccessToken(); + if (!requestHeaders?.Authorization && accessToken != null) { + // Always include the JWT access token if we have one. + requestHeaders.Authorization = `Bearer ${accessToken}`; + } + + /** + * As the fetch API is being used, when multipart/form-data is specified + * the Content-Type header must be deleted so that the browser can set + * the correct boundary. + * https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object + */ + if (requestHeaders["Content-Type"].toLowerCase().includes("multipart/form-data")) { + delete requestHeaders["Content-Type"]; + } + + // The promise is ignored on purpose. Further progress of the request is tracked through + // redux. + dispatchRequest(fullUrl, { + signal, + method, + body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined, + headers: requestHeaders + }); +} diff --git a/src/hooks/useUserData.ts b/src/hooks/useUserData.ts index ec1db36fa..076944ab9 100644 --- a/src/hooks/useUserData.ts +++ b/src/hooks/useUserData.ts @@ -1,7 +1,9 @@ import { loginConnection } from "@/connections/Login"; +import { myUserConnection } from "@/connections/User"; import { useGetAuthMe } from "@/generated/apiComponents"; import { MeResponse } from "@/generated/apiSchemas"; import { useConnection } from "@/hooks/useConnection"; +import Log from "@/utils/log"; /** * To easily access user data @@ -12,6 +14,8 @@ import { useConnection } from "@/hooks/useConnection"; */ export const useUserData = () => { const [, { token }] = useConnection(loginConnection); + const [myUserLoading, myUserResult] = useConnection(myUserConnection); + Log.debug("myUserConnection", myUserLoading, myUserResult); const { data: authMe } = useGetAuthMe<{ data: MeResponse }>( {}, { diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index f932f453e..66c08cbd7 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -36,11 +36,13 @@ type Relationship = { meta?: Attributes; }; -type StoreResource = { +export type Relationships = { + [key: string]: Relationship | Relationship[]; +}; + +export type StoreResource = { attributes: AttributeType; - relationships?: { - [key: string]: Relationship | Relationship[]; - }; + relationships?: Relationships; }; type StoreResourceMap = Record>; @@ -121,6 +123,11 @@ export const apiSlice = createSlice({ // All response objects from the v3 api conform to JsonApiResponse let { data } = response; if (!isArray(data)) data = [data]; + if (response.included != null) { + // For the purposes of this reducer, data and included are the same: they both get merged + // into the data cache. + data = [...data, ...response.included]; + } for (const resource of data) { // The data resource type is expected to match what is declared above in ApiDataStore, but // there isn't a way to enforce that with TS against this dynamic data structure, so we @@ -128,6 +135,10 @@ export const apiSlice = createSlice({ const { type, id, ...rest } = resource; state[type][id] = rest as StoreResource; } + + if (url.endsWith("/users/me") && method === "GET") { + state.meta.meUserId = (response.data as JsonApiResource).id; + } }, clearApiCache: state => { @@ -170,29 +181,33 @@ authListenerMiddleware.startListening({ }); export default class ApiSlice { - private static _store: Store; + private static _redux: Store; + + static set redux(store: Store) { + this._redux = store; + } - static set store(store: Store) { - this._store = store; + static get redux(): Store { + return this._redux; } - static get store(): Store { - return this._store; + static get apiDataStore(): ApiDataStore { + return this.redux.getState().api; } static fetchStarting(props: ApiFetchStartingProps) { - this.store.dispatch(apiSlice.actions.apiFetchStarting(props)); + this.redux.dispatch(apiSlice.actions.apiFetchStarting(props)); } static fetchFailed(props: ApiFetchFailedProps) { - this.store.dispatch(apiSlice.actions.apiFetchFailed(props)); + this.redux.dispatch(apiSlice.actions.apiFetchFailed(props)); } static fetchSucceeded(props: ApiFetchSucceededProps) { - this.store.dispatch(apiSlice.actions.apiFetchSucceeded(props)); + this.redux.dispatch(apiSlice.actions.apiFetchSucceeded(props)); } static clearApiCache() { - this.store.dispatch(apiSlice.actions.clearApiCache()); + this.redux.dispatch(apiSlice.actions.clearApiCache()); } } diff --git a/src/store/store.ts b/src/store/store.ts index b9f78bf74..9d38ef319 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -26,7 +26,7 @@ export const makeStore = (authToken?: string): Store => { store.dispatch(apiSlice.actions.setInitialAuthToken({ authToken })); } - ApiSlice.store = store; + ApiSlice.redux = store; return store; }; From dbe7c3ebc7511d3bfb653d9c4cd866cd521c90f3 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 26 Sep 2024 23:33:16 -0700 Subject: [PATCH 018/102] [TM-1312] Implement useConnections --- src/hooks/useConnection.ts | 69 +++++++++++++++++++++++++++++++++++++- src/hooks/useUserData.ts | 7 ++-- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/hooks/useConnection.ts b/src/hooks/useConnection.ts index 8b31f294b..16dd0b72b 100644 --- a/src/hooks/useConnection.ts +++ b/src/hooks/useConnection.ts @@ -1,8 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; +/* eslint-disable no-redeclare */ +import { useCallback, useEffect, useRef, useState } from "react"; import { useStore } from "react-redux"; import { AppStore } from "@/store/store"; import { Connected, Connection, OptionalProps } from "@/types/connection"; +import Log from "@/utils/log"; /** * Use a connection to efficiently depend on data in the Redux store. @@ -52,3 +54,68 @@ export function useConnection( + connections: [Connection, Connection], + props?: P1 & P2 +): readonly [boolean, [S1, S2]]; +export function useConnections< + S1, + S2, + S3, + P1 extends OptionalProps, + P2 extends OptionalProps, + P3 extends OptionalProps +>(connections: [Connection, Connection], props?: P1 & P2 & P3): readonly [boolean, [S1, S2, S3]]; +export function useConnections< + S1, + S2, + S3, + S4, + P1 extends OptionalProps, + P2 extends OptionalProps, + P3 extends OptionalProps, + P4 extends OptionalProps +>( + connections: [Connection, Connection, Connection, Connection], + props?: P1 & P2 & P3 & P4 +): readonly [boolean, [S1, S2, S3, S4]]; +export function useConnections< + S1, + S2, + S3, + S4, + S5, + P1 extends OptionalProps, + P2 extends OptionalProps, + P3 extends OptionalProps, + P4 extends OptionalProps, + P5 extends OptionalProps +>( + connections: [Connection, Connection, Connection, Connection, Connection], + props?: P1 & P2 & P3 & P4 & P5 +): readonly [boolean, [S1, S2, S3, S4, S5]]; + +/** + * A convenience function to depend on multiple connections, and receive a single "loaded" flag + * for all of them. + */ +export function useConnections( + connections: Connection[], + props: Record = {} +): readonly [boolean, unknown[]] { + const numConnections = useRef(connections.length); + if (numConnections.current !== connections.length) { + // We're violating the rules of hooks by running hooks in a loop below, so let's scream about + // it extra loud if the number of connections changes. + Log.error("NUMBER OF CONNECTIONS CHANGED!", { original: numConnections.current, current: connections.length }); + } + + return connections.reduce( + ([allLoaded, connecteds], connection) => { + const [loaded, connected] = useConnection(connection, props); + return [loaded && allLoaded, [...connecteds, connected]]; + }, + [true, []] as readonly [boolean, unknown[]] + ); +} diff --git a/src/hooks/useUserData.ts b/src/hooks/useUserData.ts index 076944ab9..191867de3 100644 --- a/src/hooks/useUserData.ts +++ b/src/hooks/useUserData.ts @@ -2,7 +2,7 @@ import { loginConnection } from "@/connections/Login"; import { myUserConnection } from "@/connections/User"; import { useGetAuthMe } from "@/generated/apiComponents"; import { MeResponse } from "@/generated/apiSchemas"; -import { useConnection } from "@/hooks/useConnection"; +import { useConnections } from "@/hooks/useConnection"; import Log from "@/utils/log"; /** @@ -13,9 +13,8 @@ import Log from "@/utils/log"; * every 5 minutes for every component that uses this hook. */ export const useUserData = () => { - const [, { token }] = useConnection(loginConnection); - const [myUserLoading, myUserResult] = useConnection(myUserConnection); - Log.debug("myUserConnection", myUserLoading, myUserResult); + const [loaded, [{ token }, myUserResult]] = useConnections([loginConnection, myUserConnection]); + Log.debug("myUserConnection", loaded, myUserResult); const { data: authMe } = useGetAuthMe<{ data: MeResponse }>( {}, { From 75919d937d5d4dd4566699e0a590764e962398fd Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 27 Sep 2024 10:41:29 -0700 Subject: [PATCH 019/102] [TM-1312] Implement connections that take props. --- src/connections/Organisation.ts | 35 +++++++++++++++++ src/hooks/useConnection.ts | 69 +------------------------------- src/hooks/useConnections.ts | 70 +++++++++++++++++++++++++++++++++ src/hooks/useUserData.ts | 8 +++- src/store/apiSlice.ts | 63 ++++++++++++++++++----------- src/utils/selectorCache.ts | 29 ++++++++++++++ 6 files changed, 182 insertions(+), 92 deletions(-) create mode 100644 src/connections/Organisation.ts create mode 100644 src/hooks/useConnections.ts create mode 100644 src/utils/selectorCache.ts diff --git a/src/connections/Organisation.ts b/src/connections/Organisation.ts new file mode 100644 index 000000000..2eb0db3c8 --- /dev/null +++ b/src/connections/Organisation.ts @@ -0,0 +1,35 @@ +import { createSelector } from "reselect"; + +import { OrganisationDto } from "@/generated/v3/userService/userServiceSchemas"; +import { ApiDataStore } from "@/store/apiSlice"; +import { Connection } from "@/types/connection"; +import { selectorCache } from "@/utils/selectorCache"; + +type OrganisationConnection = { + organisation?: OrganisationDto; + + /** + * Only included when this connection gets chained with a UserConnection so the meta on the + * relationship is available. + */ + userStatus?: "approved" | "rejected" | "requested"; +}; + +type OrganisationConnectionProps = { + organisationId?: string; +}; + +const organisationSelector = (organisationId?: string) => (store: ApiDataStore) => + organisationId == null ? undefined : store.organisations?.[organisationId]; + +export const organisationConnection: Connection = { + // TODO: This doesn't get a load/isLoaded until we have a v3 organisation get endpoint. For now + // we have to rely on the data that gets included in the users/me response. + selector: selectorCache( + ({ organisationId }) => organisationId ?? "", + ({ organisationId }) => + createSelector([organisationSelector(organisationId)], org => ({ + organisation: org?.attributes + })) + ) +}; diff --git a/src/hooks/useConnection.ts b/src/hooks/useConnection.ts index 16dd0b72b..8b31f294b 100644 --- a/src/hooks/useConnection.ts +++ b/src/hooks/useConnection.ts @@ -1,10 +1,8 @@ -/* eslint-disable no-redeclare */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useStore } from "react-redux"; import { AppStore } from "@/store/store"; import { Connected, Connection, OptionalProps } from "@/types/connection"; -import Log from "@/utils/log"; /** * Use a connection to efficiently depend on data in the Redux store. @@ -54,68 +52,3 @@ export function useConnection( - connections: [Connection, Connection], - props?: P1 & P2 -): readonly [boolean, [S1, S2]]; -export function useConnections< - S1, - S2, - S3, - P1 extends OptionalProps, - P2 extends OptionalProps, - P3 extends OptionalProps ->(connections: [Connection, Connection], props?: P1 & P2 & P3): readonly [boolean, [S1, S2, S3]]; -export function useConnections< - S1, - S2, - S3, - S4, - P1 extends OptionalProps, - P2 extends OptionalProps, - P3 extends OptionalProps, - P4 extends OptionalProps ->( - connections: [Connection, Connection, Connection, Connection], - props?: P1 & P2 & P3 & P4 -): readonly [boolean, [S1, S2, S3, S4]]; -export function useConnections< - S1, - S2, - S3, - S4, - S5, - P1 extends OptionalProps, - P2 extends OptionalProps, - P3 extends OptionalProps, - P4 extends OptionalProps, - P5 extends OptionalProps ->( - connections: [Connection, Connection, Connection, Connection, Connection], - props?: P1 & P2 & P3 & P4 & P5 -): readonly [boolean, [S1, S2, S3, S4, S5]]; - -/** - * A convenience function to depend on multiple connections, and receive a single "loaded" flag - * for all of them. - */ -export function useConnections( - connections: Connection[], - props: Record = {} -): readonly [boolean, unknown[]] { - const numConnections = useRef(connections.length); - if (numConnections.current !== connections.length) { - // We're violating the rules of hooks by running hooks in a loop below, so let's scream about - // it extra loud if the number of connections changes. - Log.error("NUMBER OF CONNECTIONS CHANGED!", { original: numConnections.current, current: connections.length }); - } - - return connections.reduce( - ([allLoaded, connecteds], connection) => { - const [loaded, connected] = useConnection(connection, props); - return [loaded && allLoaded, [...connecteds, connected]]; - }, - [true, []] as readonly [boolean, unknown[]] - ); -} diff --git a/src/hooks/useConnections.ts b/src/hooks/useConnections.ts new file mode 100644 index 000000000..9a3c67dad --- /dev/null +++ b/src/hooks/useConnections.ts @@ -0,0 +1,70 @@ +/* eslint-disable no-redeclare */ +import { useRef } from "react"; + +import { useConnection } from "@/hooks/useConnection"; +import { Connection, OptionalProps } from "@/types/connection"; +import Log from "@/utils/log"; + +export function useConnections( + connections: [Connection, Connection], + props?: P1 & P2 +): readonly [boolean, [S1, S2]]; +export function useConnections< + S1, + S2, + S3, + P1 extends OptionalProps, + P2 extends OptionalProps, + P3 extends OptionalProps +>(connections: [Connection, Connection], props?: P1 & P2 & P3): readonly [boolean, [S1, S2, S3]]; +export function useConnections< + S1, + S2, + S3, + S4, + P1 extends OptionalProps, + P2 extends OptionalProps, + P3 extends OptionalProps, + P4 extends OptionalProps +>( + connections: [Connection, Connection, Connection, Connection], + props?: P1 & P2 & P3 & P4 +): readonly [boolean, [S1, S2, S3, S4]]; +export function useConnections< + S1, + S2, + S3, + S4, + S5, + P1 extends OptionalProps, + P2 extends OptionalProps, + P3 extends OptionalProps, + P4 extends OptionalProps, + P5 extends OptionalProps +>( + connections: [Connection, Connection, Connection, Connection, Connection], + props?: P1 & P2 & P3 & P4 & P5 +): readonly [boolean, [S1, S2, S3, S4, S5]]; + +/** + * A convenience hook to depend on multiple connections, and receive a single "loaded" flag for all of them. + */ +export function useConnections( + connections: Connection[], + props: Record = {} +): readonly [boolean, unknown[]] { + const numConnections = useRef(connections.length); + if (numConnections.current !== connections.length) { + // We're violating the rules of hooks by running hooks in a loop below, so let's scream about + // it extra loud if the number of connections changes. + Log.error("NUMBER OF CONNECTIONS CHANGED!", { original: numConnections.current, current: connections.length }); + } + + return connections.reduce( + ([allLoaded, connecteds], connection) => { + const [loaded, connected] = useConnection(connection, props); + return [loaded && allLoaded, [...connecteds, connected]]; + }, + [true, []] as readonly [boolean, unknown[]] + ); +} diff --git a/src/hooks/useUserData.ts b/src/hooks/useUserData.ts index 191867de3..7851fe6bb 100644 --- a/src/hooks/useUserData.ts +++ b/src/hooks/useUserData.ts @@ -1,8 +1,10 @@ import { loginConnection } from "@/connections/Login"; +import { organisationConnection } from "@/connections/Organisation"; import { myUserConnection } from "@/connections/User"; import { useGetAuthMe } from "@/generated/apiComponents"; import { MeResponse } from "@/generated/apiSchemas"; -import { useConnections } from "@/hooks/useConnection"; +import { useConnection } from "@/hooks/useConnection"; +import { useConnections } from "@/hooks/useConnections"; import Log from "@/utils/log"; /** @@ -14,7 +16,9 @@ import Log from "@/utils/log"; */ export const useUserData = () => { const [loaded, [{ token }, myUserResult]] = useConnections([loginConnection, myUserConnection]); - Log.debug("myUserConnection", loaded, myUserResult); + const organisationId = myUserResult?.userRelationships?.org?.[0]?.id; + const [, organisationResult] = useConnection(organisationConnection, { organisationId }); + Log.debug("myUserConnection", loaded, myUserResult, organisationResult); const { data: authMe } = useGetAuthMe<{ data: MeResponse }>( {}, { diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index 66c08cbd7..aeca13e63 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -1,4 +1,5 @@ import { createListenerMiddleware, createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { WritableDraft } from "immer"; import { isArray } from "lodash"; import { Store } from "redux"; @@ -37,11 +38,13 @@ type Relationship = { }; export type Relationships = { - [key: string]: Relationship | Relationship[]; + [key: string]: Relationship[]; }; export type StoreResource = { attributes: AttributeType; + // We do a bit of munging on the shape from the API, removing the intermediate "data" member, and + // ensuring there's always an array, to make consuming the data clientside a little smoother. relationships?: Relationships; }; @@ -61,7 +64,7 @@ export type JsonApiResource = { type: (typeof RESOURCES)[number]; id: string; attributes: Attributes; - relationships?: Relationship | Relationship[]; + relationships?: { [key: string]: { data: Relationship | Relationship[] } }; }; export type JsonApiResponse = { @@ -104,6 +107,19 @@ type ApiFetchSucceededProps = ApiFetchStartingProps & { response: JsonApiResponse; }; +const clearApiCache = (state: WritableDraft) => { + for (const resource of RESOURCES) { + state[resource] = {}; + } + + for (const method of METHODS) { + state.meta.pending[method] = {}; + } +}; + +const isLogin = ({ url, method }: { url: string; method: Method }) => + url.endsWith("auth/v3/logins") && method === "POST"; + export const apiSlice = createSlice({ name: "api", initialState, @@ -118,38 +134,43 @@ export const apiSlice = createSlice({ }, apiFetchSucceeded: (state, action: PayloadAction) => { const { url, method, response } = action.payload; - delete state.meta.pending[method][url]; + if (isLogin(action.payload)) { + // After a successful login, clear the entire cache; we want all mounted components to + // re-fetch their data with the new login credentials. + clearApiCache(state); + } else { + delete state.meta.pending[method][url]; + } // All response objects from the v3 api conform to JsonApiResponse - let { data } = response; + let { data, included } = response; if (!isArray(data)) data = [data]; - if (response.included != null) { + if (included != null) { // For the purposes of this reducer, data and included are the same: they both get merged // into the data cache. - data = [...data, ...response.included]; + data = [...data, ...included]; } for (const resource of data) { // The data resource type is expected to match what is declared above in ApiDataStore, but // there isn't a way to enforce that with TS against this dynamic data structure, so we // use the dreaded any. - const { type, id, ...rest } = resource; - state[type][id] = rest as StoreResource; + const { type, id, attributes, relationships: responseRelationships } = resource; + const storeResource: StoreResource = { attributes }; + if (responseRelationships != null) { + storeResource.relationships = {}; + for (const [key, { data }] of Object.entries(responseRelationships)) { + storeResource.relationships[key] = Array.isArray(data) ? data : [data]; + } + } + state[type][id] = storeResource; } - if (url.endsWith("/users/me") && method === "GET") { + if (url.endsWith("users/v3/users/me") && method === "GET") { state.meta.meUserId = (response.data as JsonApiResource).id; } }, - clearApiCache: state => { - for (const resource of RESOURCES) { - state[resource] = {}; - } - - for (const method of METHODS) { - state.meta.pending[method] = {}; - } - }, + clearApiCache, // only used during app bootup. setInitialAuthToken: (state, action: PayloadAction<{ authToken: string }>) => { @@ -172,10 +193,8 @@ authListenerMiddleware.startListening({ response: JsonApiResponse; }> ) => { - const { url, method, response } = action.payload; - if (!url.endsWith("auth/v3/logins") || method !== "POST") return; - - const { token } = (response.data as JsonApiResource).attributes as LoginDto; + if (!isLogin(action.payload)) return; + const { token } = (action.payload.response.data as JsonApiResource).attributes as LoginDto; setAccessToken(token); } }); diff --git a/src/utils/selectorCache.ts b/src/utils/selectorCache.ts new file mode 100644 index 000000000..8ea7a3b3b --- /dev/null +++ b/src/utils/selectorCache.ts @@ -0,0 +1,29 @@ +import { ApiDataStore } from "@/store/apiSlice"; +import { Selector } from "@/types/connection"; + +type PureSelector = (store: ApiDataStore) => S; + +/** + * A factory and cache pattern for creating pure selectors from the ApiDataStore. This allows + * a connection that takes a given set of props, and is likely to get called many times during the + * lifecycle of the component to ensure that its selectors aren't getting re-created on every + * render, and are therefore going to get the performance gains we want from reselect. + * + * @param keyFactory A method that returns a string representation of the hooks props + * @param selectorFactory A method that returns a pure (store-only) selector. + */ +export function selectorCache>( + keyFactory: (props: P) => string, + selectorFactory: (props: P) => PureSelector +): Selector { + const selectors = new Map>(); + + return (store: ApiDataStore, props: P) => { + const key = keyFactory(props); + let selector = selectors.get(key); + if (selector == null) { + selectors.set(key, (selector = selectorFactory(props))); + } + return selector(store); + }; +} From ab75cd925c8ebc7f94f9c48e6f6164a9d5f11b12 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 27 Sep 2024 13:17:48 -0700 Subject: [PATCH 020/102] [TM-1312] Get rid of v1/2 users/me access. --- .../CommentarySection/CommentarySection.tsx | 15 +++---- .../DataTable/RHFCoreTeamLeadersTable.tsx | 6 +-- .../DataTable/RHFFundingTypeDataTable.tsx | 6 +-- .../DataTable/RHFLeadershipTeamTable.tsx | 6 +-- .../DataTable/RHFOwnershipStakeTable.tsx | 6 +-- .../extensive/Modal/ModalWithLogo.tsx | 15 +++---- .../extensive/WelcomeTour/WelcomeTour.tsx | 17 +++---- .../generic/Navbar/NavbarContent.tsx | 4 +- src/components/generic/Navbar/navbarItems.ts | 12 ++--- src/connections/Organisation.ts | 35 ++++++++++++--- src/connections/User.ts | 4 +- .../options/userFrameworksChoices.ts | 13 +++--- .../v3/userService/userServiceSchemas.ts | 1 + src/generated/v3/utils.ts | 23 +++++++--- src/hooks/useConnections.ts | 5 ++- src/hooks/useMyOrg.ts | 21 --------- src/hooks/useUserData.ts | 31 ------------- src/middleware.page.ts | 45 +++++++++---------- src/pages/form/[id]/pitch-select.page.tsx | 10 ++--- src/pages/home.page.tsx | 17 ++++--- src/pages/my-projects/index.page.tsx | 7 +-- src/pages/opportunities/index.page.tsx | 17 ++++--- src/pages/organization/create/index.page.tsx | 7 +-- .../organization/status/pending.page.tsx | 7 +-- .../organization/status/rejected.page.tsx | 7 +-- src/store/apiSlice.ts | 6 +++ src/store/store.ts | 26 ++++++++--- src/utils/loadConnection.ts | 30 +++++++++++++ 28 files changed, 218 insertions(+), 181 deletions(-) delete mode 100644 src/hooks/useMyOrg.ts delete mode 100644 src/hooks/useUserData.ts create mode 100644 src/utils/loadConnection.ts diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx index 15ec3b9e2..c935dd433 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx @@ -3,7 +3,8 @@ import { When } from "react-if"; import CommentaryBox from "@/components/elements/CommentaryBox/CommentaryBox"; import Text from "@/components/elements/Text/Text"; import Loader from "@/components/generic/Loading/Loader"; -import { useGetAuthMe } from "@/generated/apiComponents"; +import { myUserConnection } from "@/connections/User"; +import { useConnection } from "@/hooks/useConnection"; import { AuditLogEntity } from "../../../AuditLogTab/constants/types"; @@ -20,20 +21,14 @@ const CommentarySection = ({ viewCommentsList?: boolean; loading?: boolean; }) => { - const { data: authMe } = useGetAuthMe({}) as { - data: { - data: any; - first_name: string; - last_name: string; - }; - }; + const [, { user }] = useConnection(myUserConnection); return (
Send Comment = ({ } }); - const { data: authMe } = useGetAuthMe<{ data: GetAuthMeResponse }>({}); + const [, { user }] = useConnection(myUserConnection); const [commentsAuditLogData, restAuditLogData] = useMemo(() => { const commentsAuditLog: GetV2AuditStatusENTITYUUIDResponse = []; @@ -124,8 +121,8 @@ const ModalWithLogo: FC = ({
= ({ tourId, tourSteps, onFinish, onStart, onDontS const { setIsOpen: setIsNavOpen, setLinksDisabled: setNavLinksDisabled } = useNavbarContext(); const isLg = useMediaQuery("(min-width:1024px)"); - const userData = useUserData(); + const [, { user }] = useConnection(myUserConnection); - const TOUR_COMPLETED_STORAGE_KEY = `${tourId}_${TOUR_COMPLETED_KEY}_${userData?.uuid}`; + const TOUR_COMPLETED_STORAGE_KEY = `${tourId}_${TOUR_COMPLETED_KEY}_${user?.uuid}`; const TOUR_SKIPPED_STORAGE_KEY = `${tourId}_${TOUR_SKIPPED_KEY}`; const floaterProps = useMemo(() => { @@ -79,16 +80,16 @@ const WelcomeTour: FC = ({ tourId, tourSteps, onFinish, onStart, onDontS }, [closeModal, isLg, setIsNavOpen, setNavLinksDisabled]); const handleDontShowAgain = useCallback(() => { - if (userData?.uuid) { + if (user?.uuid) { localStorage.setItem(TOUR_COMPLETED_STORAGE_KEY, "true"); onDontShowAgain?.(); setModalInteracted(true); closeModal(ModalId.WELCOME_MODAL); } - }, [TOUR_COMPLETED_STORAGE_KEY, closeModal, onDontShowAgain, userData?.uuid]); + }, [TOUR_COMPLETED_STORAGE_KEY, closeModal, onDontShowAgain, user?.uuid]); useEffect(() => { - const userId = userData?.uuid?.toString(); + const userId = user?.uuid?.toString(); if (userId) { const isSkipped = sessionStorage.getItem(TOUR_SKIPPED_STORAGE_KEY) === "true"; const isCompleted = localStorage.getItem(TOUR_COMPLETED_STORAGE_KEY) === "true"; @@ -105,7 +106,7 @@ const WelcomeTour: FC = ({ tourId, tourSteps, onFinish, onStart, onDontS } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hasWelcomeModal, modalInteracted, userData?.uuid]); + }, [hasWelcomeModal, modalInteracted, user?.uuid]); useEffect(() => { if (tourEnabled) { @@ -134,7 +135,7 @@ const WelcomeTour: FC = ({ tourId, tourSteps, onFinish, onStart, onDontS } }} callback={data => { - if (data.status === "finished" && userData?.uuid) { + if (data.status === "finished" && user?.uuid) { localStorage.setItem(TOUR_COMPLETED_STORAGE_KEY, "true"); setTourEnabled(false); setNavLinksDisabled?.(false); diff --git a/src/components/generic/Navbar/NavbarContent.tsx b/src/components/generic/Navbar/NavbarContent.tsx index 2ce838f26..12f2ef7d1 100644 --- a/src/components/generic/Navbar/NavbarContent.tsx +++ b/src/components/generic/Navbar/NavbarContent.tsx @@ -8,10 +8,10 @@ import LanguagesDropdown from "@/components/elements/Inputs/LanguageDropdown/Lan import { IconNames } from "@/components/extensive/Icon/Icon"; import List from "@/components/extensive/List/List"; import { loginConnection } from "@/connections/Login"; +import { myOrganisationConnection } from "@/connections/Organisation"; import { useNavbarContext } from "@/context/navbar.provider"; import { useLogout } from "@/hooks/logout"; import { useConnection } from "@/hooks/useConnection"; -import { useMyOrg } from "@/hooks/useMyOrg"; import { OptionValue } from "@/types/common"; import NavbarItem from "./NavbarItem"; @@ -25,7 +25,7 @@ const NavbarContent = ({ handleClose, ...rest }: NavbarContentProps) => { const [, { isLoggedIn }] = useConnection(loginConnection); const router = useRouter(); const t = useT(); - const myOrg = useMyOrg(); + const [, myOrg] = useConnection(myOrganisationConnection); const logout = useLogout(); const { private: privateNavItems, public: publicNavItems } = getNavbarItems(t, myOrg); diff --git a/src/components/generic/Navbar/navbarItems.ts b/src/components/generic/Navbar/navbarItems.ts index 406ddc8e2..968f24f5f 100644 --- a/src/components/generic/Navbar/navbarItems.ts +++ b/src/components/generic/Navbar/navbarItems.ts @@ -1,8 +1,8 @@ import { useT } from "@transifex/react"; import { tourSelectors } from "@/components/extensive/WelcomeTour/useGetHomeTourItems"; +import { MyOrganisationConnection } from "@/connections/Organisation"; import { zendeskSupportLink } from "@/constants/links"; -import { V2MonitoringOrganisationRead } from "@/generated/apiSchemas"; interface INavbarItem { title: string; @@ -15,10 +15,10 @@ interface INavbarItems { private: INavbarItem[]; } -export const getNavbarItems = (t: typeof useT, myOrg?: V2MonitoringOrganisationRead | null): INavbarItems => { - const visibility = Boolean( - myOrg && myOrg?.status !== "rejected" && myOrg?.status !== "draft" && myOrg.users_status !== "requested" - ); +export const getNavbarItems = (t: typeof useT, myOrg?: MyOrganisationConnection): INavbarItems => { + const { userStatus, organisation } = myOrg ?? {}; + const { status } = organisation ?? {}; + const visibility = Boolean(organisation && status !== "rejected" && status !== "draft" && userStatus !== "requested"); return { public: [ @@ -53,7 +53,7 @@ export const getNavbarItems = (t: typeof useT, myOrg?: V2MonitoringOrganisationR }, { title: t("My Organization"), - url: myOrg?.uuid ? `/organization/${myOrg?.uuid}` : "/", + url: myOrg?.organisationId ? `/organization/${myOrg?.organisationId}` : "/", visibility, tourTarget: tourSelectors.ORGANIZATION }, diff --git a/src/connections/Organisation.ts b/src/connections/Organisation.ts index 2eb0db3c8..9ace0032c 100644 --- a/src/connections/Organisation.ts +++ b/src/connections/Organisation.ts @@ -1,5 +1,6 @@ import { createSelector } from "reselect"; +import { selectMe } from "@/connections/User"; import { OrganisationDto } from "@/generated/v3/userService/userServiceSchemas"; import { ApiDataStore } from "@/store/apiSlice"; import { Connection } from "@/types/connection"; @@ -7,24 +8,27 @@ import { selectorCache } from "@/utils/selectorCache"; type OrganisationConnection = { organisation?: OrganisationDto; +}; - /** - * Only included when this connection gets chained with a UserConnection so the meta on the - * relationship is available. - */ - userStatus?: "approved" | "rejected" | "requested"; +type UserStatus = "approved" | "rejected" | "requested"; +export type MyOrganisationConnection = OrganisationConnection & { + organisationId?: string; + userStatus?: UserStatus; }; type OrganisationConnectionProps = { organisationId?: string; }; +const selectOrganisations = (store: ApiDataStore) => store.organisations; const organisationSelector = (organisationId?: string) => (store: ApiDataStore) => organisationId == null ? undefined : store.organisations?.[organisationId]; +// TODO: This doesn't get a load/isLoaded until we have a v3 organisation get endpoint. For now we +// have to rely on the data that is already in the store. We might not even end up needing this +// connection, but it does illustrate nicely how to create a connection that takes props, so I'm +// leaving it in for now. export const organisationConnection: Connection = { - // TODO: This doesn't get a load/isLoaded until we have a v3 organisation get endpoint. For now - // we have to rely on the data that gets included in the users/me response. selector: selectorCache( ({ organisationId }) => organisationId ?? "", ({ organisationId }) => @@ -33,3 +37,20 @@ export const organisationConnection: Connection = { + selector: createSelector([selectMe, selectOrganisations], (user, orgs) => { + const { id, meta } = user?.relationships?.org?.[0] ?? {}; + if (id == null) return {}; + + return { + organisationId: id, + organisation: orgs?.[id]?.attributes, + userStatus: meta?.userStatus as UserStatus + }; + }) +}; diff --git a/src/connections/User.ts b/src/connections/User.ts index a9479d591..a3decc7ab 100644 --- a/src/connections/User.ts +++ b/src/connections/User.ts @@ -14,7 +14,9 @@ type UserConnection = { const selectMeId = (store: ApiDataStore) => store.meta.meUserId; const selectUsers = (store: ApiDataStore) => store.users; -const selectMe = createSelector([selectMeId, selectUsers], (meId, users) => (meId == null ? undefined : users?.[meId])); +export const selectMe = createSelector([selectMeId, selectUsers], (meId, users) => + meId == null ? undefined : users?.[meId] +); const FIND_ME: UsersFindVariables = { pathParams: { id: "me" } }; diff --git a/src/constants/options/userFrameworksChoices.ts b/src/constants/options/userFrameworksChoices.ts index 35a194e4a..a3b4fd9c4 100644 --- a/src/constants/options/userFrameworksChoices.ts +++ b/src/constants/options/userFrameworksChoices.ts @@ -1,14 +1,15 @@ import { useMemo } from "react"; -import { useUserData } from "@/hooks/useUserData"; +import { myUserConnection } from "@/connections/User"; +import { useConnection } from "@/hooks/useConnection"; import { OptionInputType } from "@/types/common"; export const useUserFrameworkChoices = (): OptionInputType[] => { - const userData = useUserData(); + const [, { user }] = useConnection(myUserConnection); - const frameworkChoices = useMemo(() => { + return useMemo(() => { return ( - userData?.frameworks?.map( + user?.frameworks?.map( f => ({ name: f.name, @@ -16,7 +17,5 @@ export const useUserFrameworkChoices = (): OptionInputType[] => { } as OptionInputType) ) ?? [] ); - }, [userData]); - - return frameworkChoices; + }, [user]); }; diff --git a/src/generated/v3/userService/userServiceSchemas.ts b/src/generated/v3/userService/userServiceSchemas.ts index 59c257689..bd957da65 100644 --- a/src/generated/v3/userService/userServiceSchemas.ts +++ b/src/generated/v3/userService/userServiceSchemas.ts @@ -29,6 +29,7 @@ export type UserFramework = { }; export type UserDto = { + uuid: string; firstName: string; lastName: string; /** diff --git a/src/generated/v3/utils.ts b/src/generated/v3/utils.ts index 4d8450871..fc9fa5d15 100644 --- a/src/generated/v3/utils.ts +++ b/src/generated/v3/utils.ts @@ -1,6 +1,7 @@ import ApiSlice, { ApiDataStore, isErrorState, isInProgress, Method, PendingErrorState } from "@/store/apiSlice"; import Log from "@/utils/log"; -import { getAccessToken } from "@/admin/apiProvider/utils/token"; +import { loginConnection } from "@/connections/Login"; +import { Connection, OptionalProps } from "@/types/connection"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; @@ -55,12 +56,19 @@ export function fetchFailed({ const isPending = (method: Method, fullUrl: string) => ApiSlice.apiDataStore.meta.pending[method][fullUrl] != null; +// We might want this utility more generally available. I'm hoping to avoid the need more widely, but I'm not totally +// opposed to this living in utils/ if we end up having a legitimate need for it. +const selectConnection = ( + connection: Connection, + props: P | Record = {} +) => connection.selector(ApiSlice.apiDataStore, props); + async function dispatchRequest(url: string, requestInit: RequestInit) { const actionPayload = { url, method: requestInit.method as Method }; ApiSlice.fetchStarting(actionPayload); try { - const response = await window.fetch(url, requestInit); + const response = await fetch(url, requestInit); if (!response.ok) { const error = (await response.json()) as ErrorWrapper; @@ -125,10 +133,15 @@ export function serviceFetch< ...headers }; - const accessToken = typeof window === "undefined" ? null : getAccessToken(); - if (!requestHeaders?.Authorization && accessToken != null) { + // Note: there's a race condition that I haven't figured out yet: the middleware in apiSlice that + // sets the access token in localStorage is firing _after_ the action has been merged into the + // store, which means that the next connections that kick off right away don't have access to + // the token through the getAccessToken method. So, we grab it from the store instead, which is + // more reliable in this case. + const { token } = selectConnection(loginConnection); + if (!requestHeaders?.Authorization && token != null) { // Always include the JWT access token if we have one. - requestHeaders.Authorization = `Bearer ${accessToken}`; + requestHeaders.Authorization = `Bearer ${token}`; } /** diff --git a/src/hooks/useConnections.ts b/src/hooks/useConnections.ts index 9a3c67dad..a3a5fe8e4 100644 --- a/src/hooks/useConnections.ts +++ b/src/hooks/useConnections.ts @@ -16,7 +16,10 @@ export function useConnections< P1 extends OptionalProps, P2 extends OptionalProps, P3 extends OptionalProps ->(connections: [Connection, Connection], props?: P1 & P2 & P3): readonly [boolean, [S1, S2, S3]]; +>( + connections: [Connection, Connection, Connection], + props?: P1 & P2 & P3 +): readonly [boolean, [S1, S2, S3]]; export function useConnections< S1, S2, diff --git a/src/hooks/useMyOrg.ts b/src/hooks/useMyOrg.ts deleted file mode 100644 index 0f45c8643..000000000 --- a/src/hooks/useMyOrg.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { UserRead, V2MonitoringOrganisationRead } from "@/generated/apiSchemas"; -import { useUserData } from "@/hooks/useUserData"; - -/** - * to get current user organisation - * @returns V2MonitoringOrganisationRead user organisation - */ -export const useMyOrg = () => { - const userData = useUserData(); - - if (userData) { - return getMyOrg(userData); - } else { - return null; - } -}; - -export const getMyOrg = (userData: UserRead): V2MonitoringOrganisationRead | undefined => { - //@ts-ignore - return userData?.organisation; -}; diff --git a/src/hooks/useUserData.ts b/src/hooks/useUserData.ts deleted file mode 100644 index 7851fe6bb..000000000 --- a/src/hooks/useUserData.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { loginConnection } from "@/connections/Login"; -import { organisationConnection } from "@/connections/Organisation"; -import { myUserConnection } from "@/connections/User"; -import { useGetAuthMe } from "@/generated/apiComponents"; -import { MeResponse } from "@/generated/apiSchemas"; -import { useConnection } from "@/hooks/useConnection"; -import { useConnections } from "@/hooks/useConnections"; -import Log from "@/utils/log"; - -/** - * To easily access user data - * @returns MeResponse - * - * TODO This hooks will be replaced in TM-1312, and the user data will be cached instead of re-fetched - * every 5 minutes for every component that uses this hook. - */ -export const useUserData = () => { - const [loaded, [{ token }, myUserResult]] = useConnections([loginConnection, myUserConnection]); - const organisationId = myUserResult?.userRelationships?.org?.[0]?.id; - const [, organisationResult] = useConnection(organisationConnection, { organisationId }); - Log.debug("myUserConnection", loaded, myUserResult, organisationResult); - const { data: authMe } = useGetAuthMe<{ data: MeResponse }>( - {}, - { - enabled: !!token, - staleTime: 300_000 //Data considered fresh for 5 min to prevent excess api call - } - ); - - return authMe?.data || null; -}; diff --git a/src/middleware.page.ts b/src/middleware.page.ts index c1c0e5c13..9adb154e7 100644 --- a/src/middleware.page.ts +++ b/src/middleware.page.ts @@ -2,9 +2,10 @@ import * as Sentry from "@sentry/nextjs"; import { NextRequest } from "next/server"; import { isAdmin, UserRole } from "@/admin/apiProvider/utils/user"; -import { fetchGetAuthMe } from "@/generated/apiComponents"; -import { UserRead } from "@/generated/apiSchemas"; -import { getMyOrg } from "@/hooks/useMyOrg"; +import { myOrganisationConnection } from "@/connections/Organisation"; +import { myUserConnection } from "@/connections/User"; +import { makeStore } from "@/store/store"; +import { loadConnection } from "@/utils/loadConnection"; import { MiddlewareCacheKey, MiddlewareMatcher } from "@/utils/MiddlewareMatcher"; //Todo: refactor this logic somewhere down the line as there are lot's of if/else nested! @@ -37,49 +38,47 @@ export async function middleware(request: NextRequest) { matcher.redirect("/auth/login"); }, async () => { - //Logged-in - const response = (await fetchGetAuthMe({ - headers: { Authorization: `Bearer ${accessToken}` } - })) as { data: UserRead }; + // Set up the redux store. + makeStore(accessToken); - const userData = response.data; + const { user } = await loadConnection(myUserConnection); + const { organisationId, organisation, userStatus } = await loadConnection(myOrganisationConnection); matcher.if( - !userData?.email_address_verified_at, + !user?.emailAddressVerifiedAt, () => { //Email is not verified - matcher.redirect(`/auth/signup/confirm?email=${userData.email_address}`); + matcher.redirect(`/auth/signup/confirm?email=${user?.emailAddress}`); }, () => { //Email is verified - //@ts-ignore - const myOrg = userData && getMyOrg(userData); - const userIsAdmin = isAdmin(userData?.role as UserRole); + const userIsAdmin = isAdmin(user?.primaryRole as UserRole); - matcher.when(!!userData && userIsAdmin)?.redirect(`/admin`, { cacheResponse: true }); + matcher.when(user != null && userIsAdmin)?.redirect(`/admin`, { cacheResponse: true }); matcher - .when(!!myOrg && !!myOrg?.status && myOrg?.status !== "draft") + .when(organisation != null && organisation.status !== "draft") ?.startWith("/organization/create") ?.redirect(`/organization/create/confirm`); - matcher.when(!myOrg)?.redirect(`/organization/assign`); + matcher.when(organisation == null)?.redirect(`/organization/assign`); - matcher.when(!!myOrg && (!myOrg?.status || myOrg?.status === "draft"))?.redirect(`/organization/create`); + matcher.when(organisation?.status === "draft")?.redirect(`/organization/create`); - matcher - .when(!!myOrg && !!myOrg?.users_status && myOrg?.users_status === "requested") - ?.redirect(`/organization/status/pending`); + matcher.when(userStatus === "requested")?.redirect(`/organization/status/pending`); - matcher.when(!!myOrg)?.exact("/organization")?.redirect(`/organization/${myOrg?.uuid}`); + matcher + .when(organisationId != null) + ?.exact("/organization") + ?.redirect(`/organization/${organisationId}`); - matcher.when(!!myOrg && myOrg?.status === "rejected")?.redirect(`/organization/status/rejected`); + matcher.when(organisation?.status === "rejected")?.redirect(`/organization/status/rejected`); matcher.exact("/")?.redirect(`/home`); matcher.startWith("/auth")?.redirect("/home"); - if (!userIsAdmin && !!myOrg && myOrg.status === "approved" && myOrg?.users_status !== "requested") { + if (!userIsAdmin && organisation?.status === "approved" && userStatus !== "requested") { //Cache result if user has and approved org matcher.next().cache("/home"); } else { diff --git a/src/pages/form/[id]/pitch-select.page.tsx b/src/pages/form/[id]/pitch-select.page.tsx index ceeaf2972..fdf010633 100644 --- a/src/pages/form/[id]/pitch-select.page.tsx +++ b/src/pages/form/[id]/pitch-select.page.tsx @@ -13,10 +13,11 @@ import Form from "@/components/extensive/Form/Form"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; +import { myOrganisationConnection } from "@/connections/Organisation"; import { useGetV2FormsUUID, useGetV2ProjectPitches, usePostV2FormsSubmissions } from "@/generated/apiComponents"; import { FormRead } from "@/generated/apiSchemas"; +import { useConnection } from "@/hooks/useConnection"; import { useDate } from "@/hooks/useDate"; -import { useMyOrg } from "@/hooks/useMyOrg"; const schema = yup.object({ pitch_uuid: yup.string().required(), @@ -28,11 +29,10 @@ export type FormData = yup.InferType; const FormIntroPage = () => { const t = useT(); const router = useRouter(); - const myOrg = useMyOrg(); const { format } = useDate(); + const [, { organisationId }] = useConnection(myOrganisationConnection); const formUUID = router.query.id as string; - const orgUUID = myOrg?.uuid as string; const form = useForm({ resolver: yupResolver(schema) @@ -51,11 +51,11 @@ const FormIntroPage = () => { page: 1, per_page: 10000, //@ts-ignore - "filter[organisation_id]": orgUUID + "filter[organisation_id]": organisationId } }, { - enabled: !!orgUUID + enabled: !!organisationId } ); diff --git a/src/pages/home.page.tsx b/src/pages/home.page.tsx index 73df2e65c..20f4b962b 100644 --- a/src/pages/home.page.tsx +++ b/src/pages/home.page.tsx @@ -14,14 +14,15 @@ import TaskList from "@/components/extensive/TaskList/TaskList"; import { useGetHomeTourItems } from "@/components/extensive/WelcomeTour/useGetHomeTourItems"; import WelcomeTour from "@/components/extensive/WelcomeTour/WelcomeTour"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; +import { myOrganisationConnection } from "@/connections/Organisation"; import { useGetV2FundingProgramme } from "@/generated/apiComponents"; +import { useConnection } from "@/hooks/useConnection"; import { useAcceptInvitation } from "@/hooks/useInviteToken"; -import { useMyOrg } from "@/hooks/useMyOrg"; import { fundingProgrammeToFundingCardProps } from "@/utils/dataTransformation"; const HomePage = () => { const t = useT(); - const myOrg = useMyOrg(); + const [, { organisation, organisationId }] = useConnection(myOrganisationConnection); const route = useRouter(); const tourSteps = useGetHomeTourItems(); useAcceptInvitation(); @@ -47,7 +48,7 @@ const HomePage = () => { - + { } /> - - - + - + Funding Opportunities`)} @@ -75,7 +74,7 @@ const HomePage = () => { title: t("Organizational Information"), subtitle: t("Keep your profile updated to have more chances of having a successful application. "), actionText: t("View"), - actionUrl: `/organization/${myOrg?.uuid}`, + actionUrl: `/organization/${organisationId}`, iconProps: { name: IconNames.BRANCH_CIRCLE, className: "fill-success" @@ -87,7 +86,7 @@ const HomePage = () => { 'Start a pitch or edit your pitches to apply for funding opportunities. To go to create a pitch, manage your pitches/funding applications, tap on "view".' ), actionText: t("View"), - actionUrl: `/organization/${myOrg?.uuid}?tab=pitches`, + actionUrl: `/organization/${organisationId}?tab=pitches`, iconProps: { name: IconNames.LIGHT_BULB_CIRCLE, className: "fill-success" diff --git a/src/pages/my-projects/index.page.tsx b/src/pages/my-projects/index.page.tsx index 1dc514c19..82973b396 100644 --- a/src/pages/my-projects/index.page.tsx +++ b/src/pages/my-projects/index.page.tsx @@ -15,13 +15,14 @@ import PageFooter from "@/components/extensive/PageElements/Footer/PageFooter"; import PageHeader from "@/components/extensive/PageElements/Header/PageHeader"; import PageSection from "@/components/extensive/PageElements/Section/PageSection"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; +import { myOrganisationConnection } from "@/connections/Organisation"; import { ToastType, useToastContext } from "@/context/toast.provider"; import { GetV2MyProjectsResponse, useDeleteV2ProjectsUUID, useGetV2MyProjects } from "@/generated/apiComponents"; -import { useMyOrg } from "@/hooks/useMyOrg"; +import { useConnection } from "@/hooks/useConnection"; const MyProjectsPage = () => { const t = useT(); - const myOrg = useMyOrg(); + const [, { organisation }] = useConnection(myOrganisationConnection); const { openToast } = useToastContext(); const { data: projectsData, isLoading, refetch } = useGetV2MyProjects<{ data: GetV2MyProjectsResponse }>({}); @@ -51,7 +52,7 @@ const MyProjectsPage = () => { - + 0}> diff --git a/src/pages/opportunities/index.page.tsx b/src/pages/opportunities/index.page.tsx index 9b80013fb..658d127ae 100644 --- a/src/pages/opportunities/index.page.tsx +++ b/src/pages/opportunities/index.page.tsx @@ -18,14 +18,15 @@ import PageSection from "@/components/extensive/PageElements/Section/PageSection import ApplicationsTable from "@/components/extensive/Tables/ApplicationsTable"; import PitchesTable from "@/components/extensive/Tables/PitchesTable"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; +import { myOrganisationConnection } from "@/connections/Organisation"; import { useGetV2FundingProgramme, useGetV2MyApplications } from "@/generated/apiComponents"; -import { useMyOrg } from "@/hooks/useMyOrg"; +import { useConnection } from "@/hooks/useConnection"; import { fundingProgrammeToFundingCardProps } from "@/utils/dataTransformation"; const OpportunitiesPage = () => { const t = useT(); const route = useRouter(); - const myOrg = useMyOrg(); + const [, { organisation, organisationId }] = useConnection(myOrganisationConnection); const [pitchesCount, setPitchesCount] = useState(); const { data: fundingProgrammes, isLoading: loadingFundingProgrammes } = useGetV2FundingProgramme({ @@ -50,7 +51,7 @@ const OpportunitiesPage = () => { - + @@ -104,13 +105,15 @@ const OpportunitiesPage = () => { "You can use pitches to apply for funding opportunities. By creating a pitch, you will have a ready-to-use resource that can be used to submit applications when funding opportunities are announced." )} headerChildren={ - } > - {/* @ts-ignore missing total field in docs */} - setPitchesCount(data.meta?.total)} /> + setPitchesCount((data.meta as any)?.total)} + /> @@ -126,7 +129,7 @@ const OpportunitiesPage = () => { iconProps={{ name: IconNames.LIGHT_BULB_CIRCLE, className: "fill-success" }} ctaProps={{ as: Link, - href: `/organization/${myOrg?.uuid}/project-pitch/create/intro`, + href: `/organization/${organisationId}/project-pitch/create/intro`, children: t("Create Pitch") }} /> diff --git a/src/pages/organization/create/index.page.tsx b/src/pages/organization/create/index.page.tsx index 337c37554..87010aab5 100644 --- a/src/pages/organization/create/index.page.tsx +++ b/src/pages/organization/create/index.page.tsx @@ -7,6 +7,7 @@ import { ModalId } from "@/components/extensive/Modal/ModalConst"; import WizardForm from "@/components/extensive/WizardForm"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; +import { myOrganisationConnection } from "@/connections/Organisation"; import { useModalContext } from "@/context/modal.provider"; import { useDeleteV2OrganisationsRetractMyDraft, @@ -15,19 +16,19 @@ import { usePutV2OrganisationsUUID } from "@/generated/apiComponents"; import { V2OrganisationRead } from "@/generated/apiSchemas"; +import { useConnection } from "@/hooks/useConnection"; import { useNormalizedFormDefaultValue } from "@/hooks/useGetCustomFormSteps/useGetCustomFormSteps"; -import { useMyOrg } from "@/hooks/useMyOrg"; import { getSteps } from "./getCreateOrganisationSteps"; const CreateOrganisationForm = () => { const t = useT(); const router = useRouter(); - const myOrg = useMyOrg(); + const [, { organisationId }] = useConnection(myOrganisationConnection); const { openModal, closeModal } = useModalContext(); const queryClient = useQueryClient(); - const uuid = (myOrg?.uuid || router?.query?.uuid) as string; + const uuid = (organisationId || router?.query?.uuid) as string; const { mutate: updateOrganisation, isLoading, isSuccess } = usePutV2OrganisationsUUID({}); diff --git a/src/pages/organization/status/pending.page.tsx b/src/pages/organization/status/pending.page.tsx index ebe6cc5be..cc0b04aa4 100644 --- a/src/pages/organization/status/pending.page.tsx +++ b/src/pages/organization/status/pending.page.tsx @@ -5,11 +5,12 @@ import HandsPlantingImage from "public/images/hands-planting.webp"; import Text from "@/components/elements/Text/Text"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; -import { useMyOrg } from "@/hooks/useMyOrg"; +import { myOrganisationConnection } from "@/connections/Organisation"; +import { useConnection } from "@/hooks/useConnection"; const OrganizationPendingPage = () => { const t = useT(); - const myOrg = useMyOrg(); + const [, { organisation }] = useConnection(myOrganisationConnection); return ( @@ -23,7 +24,7 @@ const OrganizationPendingPage = () => { {t( "You'll receive an email confirmation when your request has been approved. Ask a member of your organization ({organizationName}) to approve your request.", - { organizationName: myOrg?.name } + { organizationName: organisation?.name } )}
diff --git a/src/pages/organization/status/rejected.page.tsx b/src/pages/organization/status/rejected.page.tsx index e992b80e5..2ced19e31 100644 --- a/src/pages/organization/status/rejected.page.tsx +++ b/src/pages/organization/status/rejected.page.tsx @@ -6,11 +6,12 @@ import Text from "@/components/elements/Text/Text"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; +import { myOrganisationConnection } from "@/connections/Organisation"; import { zendeskSupportLink } from "@/constants/links"; -import { useMyOrg } from "@/hooks/useMyOrg"; +import { useConnection } from "@/hooks/useConnection"; const OrganizationRejectedPage = () => { - const myOrg = useMyOrg(); + const [, { organisation }] = useConnection(myOrganisationConnection); const t = useT(); return ( @@ -25,7 +26,7 @@ const OrganizationRejectedPage = () => { {t( "Your request to create/join the organization ({ organizationName }) has been rejected. You have been locked out of the platform and your account has been rejected.", - { organizationName: myOrg?.name } + { organizationName: organisation?.name } )}
diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index aeca13e63..306a0553d 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -4,6 +4,7 @@ import { isArray } from "lodash"; import { Store } from "redux"; import { setAccessToken } from "@/admin/apiProvider/utils/token"; +import { usersFind } from "@/generated/v3/userService/userServiceComponents"; import { LoginDto, OrganisationDto, UserDto } from "@/generated/v3/userService/userServiceSchemas"; export type PendingErrorState = { @@ -115,11 +116,15 @@ const clearApiCache = (state: WritableDraft) => { for (const method of METHODS) { state.meta.pending[method] = {}; } + + reloadMe(); }; const isLogin = ({ url, method }: { url: string; method: Method }) => url.endsWith("auth/v3/logins") && method === "POST"; +const reloadMe = () => setTimeout(() => usersFind({ pathParams: { id: "me" } }), 0); + export const apiSlice = createSlice({ name: "api", initialState, @@ -179,6 +184,7 @@ export const apiSlice = createSlice({ // so we can safely fake a login into the store when we have an authToken already set in a // cookie on app bootup. state.logins["1"] = { attributes: { token: authToken } }; + reloadMe(); } } }); diff --git a/src/store/store.ts b/src/store/store.ts index 9d38ef319..dbdd1a53b 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,6 +1,6 @@ import { configureStore } from "@reduxjs/toolkit"; import { Store } from "redux"; -import { logger } from "redux-logger"; +import { createLogger } from "redux-logger"; import ApiSlice, { ApiDataStore, apiSlice, authListenerMiddleware } from "@/store/apiSlice"; @@ -8,25 +8,41 @@ export type AppStore = { api: ApiDataStore; }; +let store: Store; + export const makeStore = (authToken?: string): Store => { - const store = configureStore({ + if (store != null) return store; + + store = configureStore({ reducer: { api: apiSlice.reducer }, middleware: getDefaultMiddleware => { - if (process.env.NODE_ENV === "production" || process.env.NODE_ENV === "test") { + if ( + process.env.NEXT_RUNTIME === "nodejs" || + process.env.NODE_ENV === "production" || + process.env.NODE_ENV === "test" + ) { return getDefaultMiddleware().prepend(authListenerMiddleware.middleware); } else { + // Most of our actions include a URL, and it's useful to have that in the top level visible + // log when it's present. + const logger = createLogger({ + titleFormatter: (action: any, time: string, took: number) => { + const extra = action?.payload?.url == null ? "" : ` [${action.payload.url}]`; + return `action @ ${time} ${action.type} (in ${took.toFixed(2)} ms)${extra}`; + } + }); return getDefaultMiddleware().prepend(authListenerMiddleware.middleware).concat(logger); } } }); + ApiSlice.redux = store; + if (authToken != null) { store.dispatch(apiSlice.actions.setInitialAuthToken({ authToken })); } - ApiSlice.redux = store; - return store; }; diff --git a/src/utils/loadConnection.ts b/src/utils/loadConnection.ts new file mode 100644 index 000000000..f4912829e --- /dev/null +++ b/src/utils/loadConnection.ts @@ -0,0 +1,30 @@ +import { Unsubscribe } from "redux"; + +import ApiSlice, { ApiDataStore } from "@/store/apiSlice"; +import { Connection, OptionalProps } from "@/types/connection"; + +export async function loadConnection( + connection: Connection, + props: PType | Record = {} +) { + const { selector, isLoaded, load } = connection; + const predicate = (store: ApiDataStore) => { + const connected = selector(store, props); + const loaded = isLoaded == null || isLoaded(connected, props); + // Delay to avoid calling dispatch during store update resolution + if (!loaded && load != null) setTimeout(() => load(connected, props), 0); + return loaded; + }; + + const store = ApiSlice.apiDataStore; + if (predicate(store)) return selector(store, props); + + const unsubscribe = await new Promise(resolve => { + const unsubscribe = ApiSlice.redux.subscribe(() => { + if (predicate(ApiSlice.apiDataStore)) resolve(unsubscribe); + }); + }); + unsubscribe(); + + return selector(ApiSlice.apiDataStore, props); +} From 6513db67a2e52277e4818ec79d035360a5a14adc Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 30 Sep 2024 14:40:38 -0700 Subject: [PATCH 021/102] [TM-1312] Get the redux store to play nice with SSR. --- package.json | 1 + src/hooks/logout.ts | 4 ---- src/middleware.page.ts | 28 ++++++++++++++++++--------- src/pages/_app.tsx | 38 ++++++++++++++++++++++++------------- src/store/StoreProvider.tsx | 22 --------------------- src/store/apiSlice.ts | 26 ++++++++++++++++++++++++- src/store/store.ts | 28 +++++++++++---------------- yarn.lock | 5 +++++ 8 files changed, 86 insertions(+), 66 deletions(-) delete mode 100644 src/store/StoreProvider.tsx diff --git a/package.json b/package.json index c9539a9d7..e2ede06ef 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "mapbox-gl": "^2.15.0", "mapbox-gl-draw-circle": "^1.1.2", "next": "13.1.5", + "next-redux-wrapper": "^8.1.0", "nookies": "^2.5.2", "prettier-plugin-tailwindcss": "^0.2.2", "ra-input-rich-text": "^4.12.2", diff --git a/src/hooks/logout.ts b/src/hooks/logout.ts index b2938d82a..a9302d633 100644 --- a/src/hooks/logout.ts +++ b/src/hooks/logout.ts @@ -1,17 +1,13 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useRouter } from "next/router"; import { logout } from "@/connections/Login"; export const useLogout = () => { const queryClient = useQueryClient(); - const router = useRouter(); return () => { queryClient.getQueryCache().clear(); queryClient.clear(); logout(); - router.push("/"); - window.location.replace("/"); }; }; diff --git a/src/middleware.page.ts b/src/middleware.page.ts index 9adb154e7..492551c24 100644 --- a/src/middleware.page.ts +++ b/src/middleware.page.ts @@ -2,10 +2,8 @@ import * as Sentry from "@sentry/nextjs"; import { NextRequest } from "next/server"; import { isAdmin, UserRole } from "@/admin/apiProvider/utils/user"; -import { myOrganisationConnection } from "@/connections/Organisation"; -import { myUserConnection } from "@/connections/User"; -import { makeStore } from "@/store/store"; -import { loadConnection } from "@/utils/loadConnection"; +import { UserDto } from "@/generated/v3/userService/userServiceSchemas"; +import { resolveUrl } from "@/generated/v3/utils"; import { MiddlewareCacheKey, MiddlewareMatcher } from "@/utils/MiddlewareMatcher"; //Todo: refactor this logic somewhere down the line as there are lot's of if/else nested! @@ -38,11 +36,23 @@ export async function middleware(request: NextRequest) { matcher.redirect("/auth/login"); }, async () => { - // Set up the redux store. - makeStore(accessToken); - - const { user } = await loadConnection(myUserConnection); - const { organisationId, organisation, userStatus } = await loadConnection(myOrganisationConnection); + // The redux store isn't available yet at this point, so we do a quick manual users/me fetch + // to get the data we need to resolve routing. + const result = await fetch(resolveUrl("/users/v3/users/me"), { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}` + } + }); + const json = await result.json(); + + const user = json.data.attributes as UserDto; + const { + id: organisationId, + meta: { userStatus } + } = json.data.relationships.org.data; + const organisation = json.included[0]; matcher.if( !user?.emailAddressVerifiedAt, diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 67f11a477..95d0bd9e9 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -8,10 +8,13 @@ import App from "next/app"; import dynamic from "next/dynamic"; import { useRouter } from "next/router"; import nookies from "nookies"; +import { Provider as ReduxProvider } from "react-redux"; import Toast from "@/components/elements/Toast/Toast"; import ModalRoot from "@/components/extensive/Modal/ModalRoot"; import MainLayout from "@/components/generic/Layout/MainLayout"; +import { loginConnection } from "@/connections/Login"; +import { myUserConnection } from "@/connections/User"; import { LoadingProvider } from "@/context/loaderAdmin.provider"; import ModalProvider from "@/context/modal.provider"; import NavbarProvider from "@/context/navbar.provider"; @@ -20,7 +23,9 @@ import WrappedQueryClientProvider from "@/context/queryclient.provider"; import RouteHistoryProvider from "@/context/routeHistory.provider"; import ToastProvider from "@/context/toast.provider"; import { getServerSideTranslations, setClientSideTranslations } from "@/i18n"; -import StoreProvider from "@/store/StoreProvider"; +import { apiSlice } from "@/store/apiSlice"; +import { wrapper } from "@/store/store"; +import { loadConnection } from "@/utils/loadConnection"; import Log from "@/utils/log"; import setupYup from "@/yup.locale"; @@ -28,35 +33,37 @@ const CookieBanner = dynamic(() => import("@/components/extensive/CookieBanner/C ssr: false }); -const _App = ({ Component, pageProps, props, authToken }: AppProps & { authToken?: string; props: any }) => { +const _App = ({ Component, ...rest }: AppProps) => { const t = useT(); const router = useRouter(); const isAdmin = router.asPath.includes("/admin"); + const { store, props } = wrapper.useWrappedStore(rest); + setClientSideTranslations(props); setupYup(t); if (isAdmin) return ( - + - + - + ); else return ( - + - + @@ -65,7 +72,7 @@ const _App = ({ Component, pageProps, props, authToken }: AppProps & { authToken - + @@ -77,20 +84,25 @@ const _App = ({ Component, pageProps, props, authToken }: AppProps & { authToken - + ); }; -_App.getInitialProps = async (context: AppContext) => { +_App.getInitialProps = wrapper.getInitialAppProps(store => async (context: AppContext) => { + const authToken = nookies.get(context.ctx).accessToken; + if (authToken != null && (await loadConnection(loginConnection)).token !== authToken) { + store.dispatch(apiSlice.actions.setInitialAuthToken({ authToken })); + await loadConnection(myUserConnection); + } + const ctx = await App.getInitialProps(context); - const cookies = nookies.get(context.ctx); let translationsData = {}; try { translationsData = await getServerSideTranslations(context.ctx); } catch (err) { Log.warn("Failed to get Serverside Transifex", err); } - return { ...ctx, props: { ...translationsData }, authToken: cookies.accessToken }; -}; + return { ...ctx, props: { ...translationsData } }; +}); export default _App; diff --git a/src/store/StoreProvider.tsx b/src/store/StoreProvider.tsx deleted file mode 100644 index ad6f3e84d..000000000 --- a/src/store/StoreProvider.tsx +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; -import { useRef } from "react"; -import { Provider } from "react-redux"; -import { Store } from "redux"; - -import { AppStore, makeStore } from "./store"; - -export default function StoreProvider({ - authToken = undefined, - children -}: { - authToken?: string; - children: React.ReactNode; -}) { - const storeRef = useRef>(); - if (!storeRef.current) { - // Create the store instance the first time this renders - storeRef.current = makeStore(authToken); - } - - return {children}; -} diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index 306a0553d..1e39ed560 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -1,6 +1,7 @@ import { createListenerMiddleware, createSlice, PayloadAction } from "@reduxjs/toolkit"; import { WritableDraft } from "immer"; import { isArray } from "lodash"; +import { HYDRATE } from "next-redux-wrapper"; import { Store } from "redux"; import { setAccessToken } from "@/admin/apiProvider/utils/token"; @@ -117,7 +118,7 @@ const clearApiCache = (state: WritableDraft) => { state.meta.pending[method] = {}; } - reloadMe(); + delete state.meta.meUserId; }; const isLogin = ({ url, method }: { url: string; method: Method }) => @@ -127,7 +128,9 @@ const reloadMe = () => setTimeout(() => usersFind({ pathParams: { id: "me" } }), export const apiSlice = createSlice({ name: "api", + initialState, + reducers: { apiFetchStarting: (state, action: PayloadAction) => { const { url, method } = action.payload; @@ -143,6 +146,7 @@ export const apiSlice = createSlice({ // After a successful login, clear the entire cache; we want all mounted components to // re-fetch their data with the new login credentials. clearApiCache(state); + reloadMe(); } else { delete state.meta.pending[method][url]; } @@ -186,6 +190,26 @@ export const apiSlice = createSlice({ state.logins["1"] = { attributes: { token: authToken } }; reloadMe(); } + }, + + extraReducers: builder => { + builder.addCase(HYDRATE, (state, action) => { + clearApiCache(state); + + const { payload } = action as unknown as PayloadAction<{ api: ApiDataStore }>; + + for (const resource of RESOURCES) { + state[resource] = payload.api[resource] as any; + } + + for (const method of METHODS) { + state.meta.pending[method] = payload.api.meta.pending[method]; + } + + if (payload.api.meta.meUserId != null) { + state.meta.meUserId = payload.api.meta.meUserId; + } + }); } }); diff --git a/src/store/store.ts b/src/store/store.ts index dbdd1a53b..d82faa6c3 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,4 +1,5 @@ import { configureStore } from "@reduxjs/toolkit"; +import { createWrapper, MakeStore } from "next-redux-wrapper"; import { Store } from "redux"; import { createLogger } from "redux-logger"; @@ -8,23 +9,16 @@ export type AppStore = { api: ApiDataStore; }; -let store: Store; - -export const makeStore = (authToken?: string): Store => { - if (store != null) return store; - - store = configureStore({ +const makeStore: MakeStore> = context => { + const store = configureStore({ reducer: { api: apiSlice.reducer }, middleware: getDefaultMiddleware => { - if ( - process.env.NEXT_RUNTIME === "nodejs" || - process.env.NODE_ENV === "production" || - process.env.NODE_ENV === "test" - ) { - return getDefaultMiddleware().prepend(authListenerMiddleware.middleware); - } else { + const includeLogger = + typeof window !== "undefined" && process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test"; + + if (includeLogger) { // Most of our actions include a URL, and it's useful to have that in the top level visible // log when it's present. const logger = createLogger({ @@ -34,15 +28,15 @@ export const makeStore = (authToken?: string): Store => { } }); return getDefaultMiddleware().prepend(authListenerMiddleware.middleware).concat(logger); + } else { + return getDefaultMiddleware().prepend(authListenerMiddleware.middleware); } } }); ApiSlice.redux = store; - if (authToken != null) { - store.dispatch(apiSlice.actions.setInitialAuthToken({ authToken })); - } - return store; }; + +export const wrapper = createWrapper>(makeStore); diff --git a/yarn.lock b/yarn.lock index 3a7fe676c..6b3975eed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11595,6 +11595,11 @@ neo-async@^2.5.0, neo-async@^2.6.0, neo-async@^2.6.1, neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +next-redux-wrapper@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/next-redux-wrapper/-/next-redux-wrapper-8.1.0.tgz#d9c135f1ceeb2478375bdacd356eb9db273d3a07" + integrity sha512-2hIau0hcI6uQszOtrvAFqgc0NkZegKYhBB7ZAKiG3jk7zfuQb4E7OV9jfxViqqojh3SEHdnFfPkN9KErttUKuw== + next-router-mock@^0.9.3: version "0.9.3" resolved "https://registry.yarnpkg.com/next-router-mock/-/next-router-mock-0.9.3.tgz#8287e96d76d4c7b3720bc9078b148c2b352f1567" From 4e4f9c5d4175c1bcc4005f36a24d8d99769b85ed Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 30 Sep 2024 15:15:31 -0700 Subject: [PATCH 022/102] [TM-1312] Get the admin site working again. --- src/admin/apiProvider/authProvider.ts | 61 +++++++++++---------------- src/admin/components/App.tsx | 26 +++--------- src/admin/hooks/useGetUserRole.ts | 8 ++-- src/store/apiSlice.ts | 3 +- src/store/store.ts | 2 +- 5 files changed, 37 insertions(+), 63 deletions(-) diff --git a/src/admin/apiProvider/authProvider.ts b/src/admin/apiProvider/authProvider.ts index d9625c8f2..6a2d66fbf 100644 --- a/src/admin/apiProvider/authProvider.ts +++ b/src/admin/apiProvider/authProvider.ts @@ -1,54 +1,41 @@ import { AuthProvider } from "react-admin"; -import { fetchGetAuthLogout } from "@/generated/apiComponents"; +import { isAdmin, UserRole } from "@/admin/apiProvider/utils/user"; +import { loginConnection, logout } from "@/connections/Login"; +import { myUserConnection } from "@/connections/User"; +import { loadConnection } from "@/utils/loadConnection"; import Log from "@/utils/log"; -import { getAccessToken, removeAccessToken } from "./utils/token"; - export const authProvider: AuthProvider = { - // send username and password to the auth server and get back credentials login: async () => { Log.error("Admin app does not support direct login"); }, - // when the dataProvider returns an error, check if this is an authentication error - checkError: () => { - return Promise.resolve(); - }, + checkError: async () => {}, // when the user navigates, make sure that their credentials are still valid checkAuth: async () => { - const token = getAccessToken(); - if (!token) return Promise.reject(); - - // TODO (TM-1312) Once we have a connection for the users/me object, we can check the cached - // value without re-fetching on every navigation in the admin UI. The previous implementation - // is included below for reference until that ticket is complete. - // return new Promise((resolve, reject) => { - // fetchGetAuthMe({}) - // .then(res => { - // //@ts-ignore - // if (isAdmin(res.data.role)) resolve(); - // else reject("Only admins are allowed."); - // }) - // .catch(() => reject()); - // }); + const { user } = await loadConnection(myUserConnection); + if (user == null) throw "No user logged in."; + + if (!isAdmin(user.primaryRole as UserRole)) throw "Only admins are allowed."; }, - // remove local credentials and notify the auth server that the user logged out + + // remove local credentials logout: async () => { - const token = getAccessToken(); - if (!token) return Promise.resolve(); - - return new Promise(resolve => { - fetchGetAuthLogout({}) - .then(async () => { - removeAccessToken(); - window.location.replace("/auth/login"); - }) - .catch(() => { - resolve(); - }); - }); + console.log("LOGOUT"); + const { isLoggedIn } = await loadConnection(loginConnection); + if (isLoggedIn) { + logout(); + window.location.replace("/auth/login"); + } + }, + + getIdentity: async () => { + const { user } = await loadConnection(myUserConnection); + if (user == null) throw "No user logged in."; + + return { id: user.uuid, fullName: user.fullName, primaryRole: user.primaryRole }; }, // get the user permissions (optional) diff --git a/src/admin/components/App.tsx b/src/admin/components/App.tsx index d073f8c98..e552a531c 100644 --- a/src/admin/components/App.tsx +++ b/src/admin/components/App.tsx @@ -1,6 +1,4 @@ import SummarizeIcon from "@mui/icons-material/Summarize"; -import router from "next/router"; -import { useEffect, useState } from "react"; import { Admin, Resource } from "react-admin"; import { authProvider } from "@/admin/apiProvider/authProvider"; @@ -8,31 +6,19 @@ import { dataProvider } from "@/admin/apiProvider/dataProviders"; import { AppLayout } from "@/admin/components/AppLayout"; import { theme } from "@/admin/components/theme"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; +import { myUserConnection } from "@/connections/User"; import { LoadingProvider } from "@/context/loaderAdmin.provider"; +import { useConnection } from "@/hooks/useConnection"; import LoginPage from "@/pages/auth/login/index.page"; import modules from "../modules"; const App = () => { - const [identity, setIdentity] = useState(null); + const [, { user }] = useConnection(myUserConnection); + if (user == null) return null; - useEffect(() => { - const getIdentity = async () => { - try { - const data: any = await authProvider?.getIdentity?.(); - setIdentity(data); - } catch (error) { - router.push("/auth/login"); - } - }; - - getIdentity(); - }, []); - - if (identity == null) return null; - - const canCreate = identity.role === "admin-super"; - const isAdmin = identity.role?.includes("admin"); + const canCreate = user.primaryRole === "admin-super"; + const isAdmin = user.primaryRole.includes("admin"); return ( { const user: any = data || {}; return { - role: user.role, - isSuperAdmin: user.role === "admin-super", - isPPCAdmin: user.role === "admin-ppc", - isPPCTerrafundAdmin: user.role === "admin-terrafund" + role: user.primaryRole, + isSuperAdmin: user.primaryRole === "admin-super", + isPPCAdmin: user.primaryRole === "admin-ppc", + isPPCTerrafundAdmin: user.primaryRole === "admin-terrafund" }; }; diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index 1e39ed560..af9fadc86 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -146,6 +146,8 @@ export const apiSlice = createSlice({ // After a successful login, clear the entire cache; we want all mounted components to // re-fetch their data with the new login credentials. clearApiCache(state); + // TODO: this will no longer be needed once we have connection chaining, as the my org + // connection will force the my user connection to load. reloadMe(); } else { delete state.meta.pending[method][url]; @@ -188,7 +190,6 @@ export const apiSlice = createSlice({ // so we can safely fake a login into the store when we have an authToken already set in a // cookie on app bootup. state.logins["1"] = { attributes: { token: authToken } }; - reloadMe(); } }, diff --git a/src/store/store.ts b/src/store/store.ts index d82faa6c3..818ee3bf5 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -9,7 +9,7 @@ export type AppStore = { api: ApiDataStore; }; -const makeStore: MakeStore> = context => { +const makeStore: MakeStore> = () => { const store = configureStore({ reducer: { api: apiSlice.reducer From d19610bac5c31781ec4a4e2d8530127c4cf839c6 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 30 Sep 2024 15:55:03 -0700 Subject: [PATCH 023/102] [TM-1312] Make the default pattern for accessing connections be through shorter accessor hooks / loaders. --- src/admin/apiProvider/authProvider.ts | 12 ++- src/admin/components/App.tsx | 5 +- .../CommentarySection/CommentarySection.tsx | 5 +- .../DataTable/RHFCoreTeamLeadersTable.tsx | 5 +- .../DataTable/RHFFundingTypeDataTable.tsx | 5 +- .../DataTable/RHFLeadershipTeamTable.tsx | 5 +- .../DataTable/RHFOwnershipStakeTable.tsx | 5 +- .../extensive/Modal/ModalWithLogo.tsx | 5 +- .../extensive/WelcomeTour/WelcomeTour.tsx | 5 +- .../generic/Navbar/NavbarContent.tsx | 9 +-- src/connections/Login.ts | 6 +- src/connections/Organisation.ts | 6 +- src/connections/User.ts | 5 +- .../options/userFrameworksChoices.ts | 5 +- src/generated/apiContext.ts | 5 +- src/generated/v3/utils.ts | 12 +-- src/hooks/useConnection.ts | 2 +- src/hooks/useConnections.ts | 73 ------------------- src/pages/_app.tsx | 9 +-- src/pages/auth/login/index.page.tsx | 5 +- src/pages/form/[id]/pitch-select.page.tsx | 5 +- src/pages/home.page.tsx | 5 +- src/pages/my-projects/index.page.tsx | 5 +- src/pages/opportunities/index.page.tsx | 5 +- src/pages/organization/create/index.page.tsx | 5 +- .../organization/status/pending.page.tsx | 5 +- .../organization/status/rejected.page.tsx | 5 +- src/types/connection.ts | 2 +- src/utils/connectionShortcuts.ts | 30 ++++++++ 29 files changed, 96 insertions(+), 160 deletions(-) delete mode 100644 src/hooks/useConnections.ts create mode 100644 src/utils/connectionShortcuts.ts diff --git a/src/admin/apiProvider/authProvider.ts b/src/admin/apiProvider/authProvider.ts index 6a2d66fbf..c08b94e03 100644 --- a/src/admin/apiProvider/authProvider.ts +++ b/src/admin/apiProvider/authProvider.ts @@ -1,9 +1,8 @@ import { AuthProvider } from "react-admin"; import { isAdmin, UserRole } from "@/admin/apiProvider/utils/user"; -import { loginConnection, logout } from "@/connections/Login"; -import { myUserConnection } from "@/connections/User"; -import { loadConnection } from "@/utils/loadConnection"; +import { loadLogin, logout } from "@/connections/Login"; +import { loadMyUser } from "@/connections/User"; import Log from "@/utils/log"; export const authProvider: AuthProvider = { @@ -15,7 +14,7 @@ export const authProvider: AuthProvider = { // when the user navigates, make sure that their credentials are still valid checkAuth: async () => { - const { user } = await loadConnection(myUserConnection); + const { user } = await loadMyUser(); if (user == null) throw "No user logged in."; if (!isAdmin(user.primaryRole as UserRole)) throw "Only admins are allowed."; @@ -23,8 +22,7 @@ export const authProvider: AuthProvider = { // remove local credentials logout: async () => { - console.log("LOGOUT"); - const { isLoggedIn } = await loadConnection(loginConnection); + const { isLoggedIn } = await loadLogin(); if (isLoggedIn) { logout(); window.location.replace("/auth/login"); @@ -32,7 +30,7 @@ export const authProvider: AuthProvider = { }, getIdentity: async () => { - const { user } = await loadConnection(myUserConnection); + const { user } = await loadMyUser(); if (user == null) throw "No user logged in."; return { id: user.uuid, fullName: user.fullName, primaryRole: user.primaryRole }; diff --git a/src/admin/components/App.tsx b/src/admin/components/App.tsx index e552a531c..a3b4e8db5 100644 --- a/src/admin/components/App.tsx +++ b/src/admin/components/App.tsx @@ -6,15 +6,14 @@ import { dataProvider } from "@/admin/apiProvider/dataProviders"; import { AppLayout } from "@/admin/components/AppLayout"; import { theme } from "@/admin/components/theme"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; -import { myUserConnection } from "@/connections/User"; +import { useMyUser } from "@/connections/User"; import { LoadingProvider } from "@/context/loaderAdmin.provider"; -import { useConnection } from "@/hooks/useConnection"; import LoginPage from "@/pages/auth/login/index.page"; import modules from "../modules"; const App = () => { - const [, { user }] = useConnection(myUserConnection); + const [, { user }] = useMyUser(); if (user == null) return null; const canCreate = user.primaryRole === "admin-super"; diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx index c935dd433..a11f6f395 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx @@ -3,8 +3,7 @@ import { When } from "react-if"; import CommentaryBox from "@/components/elements/CommentaryBox/CommentaryBox"; import Text from "@/components/elements/Text/Text"; import Loader from "@/components/generic/Loading/Loader"; -import { myUserConnection } from "@/connections/User"; -import { useConnection } from "@/hooks/useConnection"; +import { useMyUser } from "@/connections/User"; import { AuditLogEntity } from "../../../AuditLogTab/constants/types"; @@ -21,7 +20,7 @@ const CommentarySection = ({ viewCommentsList?: boolean; loading?: boolean; }) => { - const [, { user }] = useConnection(myUserConnection); + const [, { user }] = useMyUser(); return (
diff --git a/src/components/elements/Inputs/DataTable/RHFCoreTeamLeadersTable.tsx b/src/components/elements/Inputs/DataTable/RHFCoreTeamLeadersTable.tsx index 3e995668a..1a388cfb3 100644 --- a/src/components/elements/Inputs/DataTable/RHFCoreTeamLeadersTable.tsx +++ b/src/components/elements/Inputs/DataTable/RHFCoreTeamLeadersTable.tsx @@ -5,10 +5,9 @@ import { useController, UseControllerProps, UseFormReturn } from "react-hook-for import * as yup from "yup"; import { FieldType } from "@/components/extensive/WizardForm/types"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { getGenderOptions } from "@/constants/options/gender"; import { useDeleteV2CoreTeamLeaderUUID, usePostV2CoreTeamLeader } from "@/generated/apiComponents"; -import { useConnection } from "@/hooks/useConnection"; import { formatOptionsList } from "@/utils/options"; import DataTable, { DataTableProps } from "./DataTable"; @@ -46,7 +45,7 @@ const RHFCoreTeamLeadersDataTable = ({ const { field } = useController(props); const value = field?.value || []; - const [, { organisationId }] = useConnection(myOrganisationConnection); + const [, { organisationId }] = useMyOrg(); const { mutate: createTeamMember } = usePostV2CoreTeamLeader({ onSuccess(data) { diff --git a/src/components/elements/Inputs/DataTable/RHFFundingTypeDataTable.tsx b/src/components/elements/Inputs/DataTable/RHFFundingTypeDataTable.tsx index e40eee3d4..af2a84ba9 100644 --- a/src/components/elements/Inputs/DataTable/RHFFundingTypeDataTable.tsx +++ b/src/components/elements/Inputs/DataTable/RHFFundingTypeDataTable.tsx @@ -5,10 +5,9 @@ import { useController, UseControllerProps, UseFormReturn } from "react-hook-for import * as yup from "yup"; import { FieldType, FormField } from "@/components/extensive/WizardForm/types"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { getFundingTypesOptions } from "@/constants/options/fundingTypes"; import { useDeleteV2FundingTypeUUID, usePostV2FundingType } from "@/generated/apiComponents"; -import { useConnection } from "@/hooks/useConnection"; import { formatOptionsList } from "@/utils/options"; import DataTable, { DataTableProps } from "./DataTable"; @@ -89,7 +88,7 @@ const RHFFundingTypeDataTable = ({ onChangeCapture, ...props }: PropsWithChildre const { field } = useController(props); const value = field?.value || []; - const [, { organisationId }] = useConnection(myOrganisationConnection); + const [, { organisationId }] = useMyOrg(); const { mutate: createTeamMember } = usePostV2FundingType({ onSuccess(data) { diff --git a/src/components/elements/Inputs/DataTable/RHFLeadershipTeamTable.tsx b/src/components/elements/Inputs/DataTable/RHFLeadershipTeamTable.tsx index 5ddf04278..273b56643 100644 --- a/src/components/elements/Inputs/DataTable/RHFLeadershipTeamTable.tsx +++ b/src/components/elements/Inputs/DataTable/RHFLeadershipTeamTable.tsx @@ -5,10 +5,9 @@ import { useController, UseControllerProps, UseFormReturn } from "react-hook-for import * as yup from "yup"; import { FieldType } from "@/components/extensive/WizardForm/types"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { getGenderOptions } from "@/constants/options/gender"; import { useDeleteV2LeadershipTeamUUID, usePostV2LeadershipTeam } from "@/generated/apiComponents"; -import { useConnection } from "@/hooks/useConnection"; import { formatOptionsList } from "@/utils/options"; import DataTable, { DataTableProps } from "./DataTable"; @@ -43,7 +42,7 @@ const RHFLeadershipTeamDataTable = ({ onChangeCapture, ...props }: PropsWithChil const { field } = useController(props); const value = field?.value || []; - const [, { organisationId }] = useConnection(myOrganisationConnection); + const [, { organisationId }] = useMyOrg(); const { mutate: createTeamMember } = usePostV2LeadershipTeam({ onSuccess(data) { diff --git a/src/components/elements/Inputs/DataTable/RHFOwnershipStakeTable.tsx b/src/components/elements/Inputs/DataTable/RHFOwnershipStakeTable.tsx index 079c62d9a..866b5373a 100644 --- a/src/components/elements/Inputs/DataTable/RHFOwnershipStakeTable.tsx +++ b/src/components/elements/Inputs/DataTable/RHFOwnershipStakeTable.tsx @@ -5,10 +5,9 @@ import { useController, UseControllerProps, UseFormReturn } from "react-hook-for import * as yup from "yup"; import { FieldType } from "@/components/extensive/WizardForm/types"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { getGenderOptions } from "@/constants/options/gender"; import { useDeleteV2OwnershipStakeUUID, usePostV2OwnershipStake } from "@/generated/apiComponents"; -import { useConnection } from "@/hooks/useConnection"; import { formatOptionsList } from "@/utils/options"; import DataTable, { DataTableProps } from "./DataTable"; @@ -42,7 +41,7 @@ const RHFOwnershipStakeTable = ({ onChangeCapture, ...props }: PropsWithChildren const { field } = useController(props); const value = field?.value || []; - const [, { organisationId }] = useConnection(myOrganisationConnection); + const [, { organisationId }] = useMyOrg(); const { mutate: createTeamMember } = usePostV2OwnershipStake({ onSuccess(data) { diff --git a/src/components/extensive/Modal/ModalWithLogo.tsx b/src/components/extensive/Modal/ModalWithLogo.tsx index 41b9e6e58..abe0e6d37 100644 --- a/src/components/extensive/Modal/ModalWithLogo.tsx +++ b/src/components/extensive/Modal/ModalWithLogo.tsx @@ -14,10 +14,9 @@ import { formatCommentaryDate } from "@/components/elements/Map-mapbox/utils"; import StepProgressbar from "@/components/elements/ProgressBar/StepProgressbar/StepProgressbar"; import { StatusEnum } from "@/components/elements/Status/constants/statusMap"; import Text from "@/components/elements/Text/Text"; -import { myUserConnection } from "@/connections/User"; +import { useMyUser } from "@/connections/User"; import { GetV2AuditStatusENTITYUUIDResponse, useGetV2AuditStatusENTITYUUID } from "@/generated/apiComponents"; import { statusActionsMap } from "@/hooks/AuditStatus/useAuditLogActions"; -import { useConnection } from "@/hooks/useConnection"; import Icon, { IconNames } from "../Icon/Icon"; import { ModalProps } from "./Modal"; @@ -57,7 +56,7 @@ const ModalWithLogo: FC = ({ } }); - const [, { user }] = useConnection(myUserConnection); + const [, { user }] = useMyUser(); const [commentsAuditLogData, restAuditLogData] = useMemo(() => { const commentsAuditLog: GetV2AuditStatusENTITYUUIDResponse = []; diff --git a/src/components/extensive/WelcomeTour/WelcomeTour.tsx b/src/components/extensive/WelcomeTour/WelcomeTour.tsx index ccba59b52..bf192837a 100644 --- a/src/components/extensive/WelcomeTour/WelcomeTour.tsx +++ b/src/components/extensive/WelcomeTour/WelcomeTour.tsx @@ -4,10 +4,9 @@ import { FC, useCallback, useEffect, useMemo, useState } from "react"; import { When } from "react-if"; import Joyride, { Step } from "react-joyride"; -import { myUserConnection } from "@/connections/User"; +import { useMyUser } from "@/connections/User"; import { useModalContext } from "@/context/modal.provider"; import { useNavbarContext } from "@/context/navbar.provider"; -import { useConnection } from "@/hooks/useConnection"; import { ModalId } from "../Modal/ModalConst"; import ToolTip from "./Tooltip"; @@ -32,7 +31,7 @@ const WelcomeTour: FC = ({ tourId, tourSteps, onFinish, onStart, onDontS const { setIsOpen: setIsNavOpen, setLinksDisabled: setNavLinksDisabled } = useNavbarContext(); const isLg = useMediaQuery("(min-width:1024px)"); - const [, { user }] = useConnection(myUserConnection); + const [, { user }] = useMyUser(); const TOUR_COMPLETED_STORAGE_KEY = `${tourId}_${TOUR_COMPLETED_KEY}_${user?.uuid}`; const TOUR_SKIPPED_STORAGE_KEY = `${tourId}_${TOUR_SKIPPED_KEY}`; diff --git a/src/components/generic/Navbar/NavbarContent.tsx b/src/components/generic/Navbar/NavbarContent.tsx index 12f2ef7d1..6b97ccdb4 100644 --- a/src/components/generic/Navbar/NavbarContent.tsx +++ b/src/components/generic/Navbar/NavbarContent.tsx @@ -7,11 +7,10 @@ import { Else, If, Then, When } from "react-if"; import LanguagesDropdown from "@/components/elements/Inputs/LanguageDropdown/LanguagesDropdown"; import { IconNames } from "@/components/extensive/Icon/Icon"; import List from "@/components/extensive/List/List"; -import { loginConnection } from "@/connections/Login"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useLogin } from "@/connections/Login"; +import { useMyOrg } from "@/connections/Organisation"; import { useNavbarContext } from "@/context/navbar.provider"; import { useLogout } from "@/hooks/logout"; -import { useConnection } from "@/hooks/useConnection"; import { OptionValue } from "@/types/common"; import NavbarItem from "./NavbarItem"; @@ -22,10 +21,10 @@ interface NavbarContentProps extends DetailedHTMLProps { - const [, { isLoggedIn }] = useConnection(loginConnection); + const [, { isLoggedIn }] = useLogin(); const router = useRouter(); const t = useT(); - const [, myOrg] = useConnection(myOrganisationConnection); + const [, myOrg] = useMyOrg(); const logout = useLogout(); const { private: privateNavItems, public: publicNavItems } = getNavbarItems(t, myOrg); diff --git a/src/connections/Login.ts b/src/connections/Login.ts index 558020461..537e22b20 100644 --- a/src/connections/Login.ts +++ b/src/connections/Login.ts @@ -5,6 +5,7 @@ import { authLogin } from "@/generated/v3/userService/userServiceComponents"; import { authLoginFetchFailed, authLoginIsFetching } from "@/generated/v3/userService/userServicePredicates"; import ApiSlice, { ApiDataStore } from "@/store/apiSlice"; import { Connection } from "@/types/connection"; +import { connectionHook, connectionLoader, connectionSelector } from "@/utils/connectionShortcuts"; type LoginConnection = { isLoggingIn: boolean; @@ -23,7 +24,7 @@ export const logout = () => { const selectFirstLogin = (store: ApiDataStore) => Object.values(store.logins)?.[0]?.attributes; -export const loginConnection: Connection = { +const loginConnection: Connection = { selector: createSelector( [authLoginIsFetching, authLoginFetchFailed, selectFirstLogin], (isLoggingIn, failedLogin, firstLogin) => { @@ -36,3 +37,6 @@ export const loginConnection: Connection = { } ) }; +export const useLogin = connectionHook(loginConnection); +export const loadLogin = connectionLoader(loginConnection); +export const selectLogin = connectionSelector(loginConnection); diff --git a/src/connections/Organisation.ts b/src/connections/Organisation.ts index 9ace0032c..10b1a936d 100644 --- a/src/connections/Organisation.ts +++ b/src/connections/Organisation.ts @@ -4,6 +4,7 @@ import { selectMe } from "@/connections/User"; import { OrganisationDto } from "@/generated/v3/userService/userServiceSchemas"; import { ApiDataStore } from "@/store/apiSlice"; import { Connection } from "@/types/connection"; +import { connectionHook } from "@/utils/connectionShortcuts"; import { selectorCache } from "@/utils/selectorCache"; type OrganisationConnection = { @@ -27,7 +28,7 @@ const organisationSelector = (organisationId?: string) => (store: ApiDataStore) // TODO: This doesn't get a load/isLoaded until we have a v3 organisation get endpoint. For now we // have to rely on the data that is already in the store. We might not even end up needing this // connection, but it does illustrate nicely how to create a connection that takes props, so I'm -// leaving it in for now. +// leaving it in for now. Exported just to keep the linter happy since it's not currently used. export const organisationConnection: Connection = { selector: selectorCache( ({ organisationId }) => organisationId ?? "", @@ -42,7 +43,7 @@ export const organisationConnection: Connection = { +const myOrganisationConnection: Connection = { selector: createSelector([selectMe, selectOrganisations], (user, orgs) => { const { id, meta } = user?.relationships?.org?.[0] ?? {}; if (id == null) return {}; @@ -54,3 +55,4 @@ export const myOrganisationConnection: Connection = { }; }) }; +export const useMyOrg = connectionHook(myOrganisationConnection); diff --git a/src/connections/User.ts b/src/connections/User.ts index a3decc7ab..86748dfe9 100644 --- a/src/connections/User.ts +++ b/src/connections/User.ts @@ -5,6 +5,7 @@ import { usersFindFetchFailed } from "@/generated/v3/userService/userServicePred import { UserDto } from "@/generated/v3/userService/userServiceSchemas"; import { ApiDataStore, Relationships } from "@/store/apiSlice"; import { Connection } from "@/types/connection"; +import { connectionHook, connectionLoader } from "@/utils/connectionShortcuts"; type UserConnection = { user?: UserDto; @@ -20,7 +21,7 @@ export const selectMe = createSelector([selectMeId, selectUsers], (meId, users) const FIND_ME: UsersFindVariables = { pathParams: { id: "me" } }; -export const myUserConnection: Connection = { +const myUserConnection: Connection = { load: ({ user }) => { if (user == null) usersFind(FIND_ME); }, @@ -33,3 +34,5 @@ export const myUserConnection: Connection = { userLoadFailed: userLoadFailure != null })) }; +export const useMyUser = connectionHook(myUserConnection); +export const loadMyUser = connectionLoader(myUserConnection); diff --git a/src/constants/options/userFrameworksChoices.ts b/src/constants/options/userFrameworksChoices.ts index a3b4fd9c4..6c26a50d3 100644 --- a/src/constants/options/userFrameworksChoices.ts +++ b/src/constants/options/userFrameworksChoices.ts @@ -1,11 +1,10 @@ import { useMemo } from "react"; -import { myUserConnection } from "@/connections/User"; -import { useConnection } from "@/hooks/useConnection"; +import { useMyUser } from "@/connections/User"; import { OptionInputType } from "@/types/common"; export const useUserFrameworkChoices = (): OptionInputType[] => { - const [, { user }] = useConnection(myUserConnection); + const [, { user }] = useMyUser(); return useMemo(() => { return ( diff --git a/src/generated/apiContext.ts b/src/generated/apiContext.ts index b1bbb667a..5d6b74081 100644 --- a/src/generated/apiContext.ts +++ b/src/generated/apiContext.ts @@ -1,8 +1,7 @@ import type { QueryKey, UseQueryOptions } from "@tanstack/react-query"; import { QueryOperation } from "./apiComponents"; -import { useConnection } from "@/hooks/useConnection"; -import { loginConnection } from "@/connections/Login"; +import { useLogin } from "@/connections/Login"; export type ApiContext = { fetcherOptions: { @@ -41,7 +40,7 @@ export function useApiContext< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey >(_queryOptions?: Omit, "queryKey" | "queryFn">): ApiContext { - const [, { token }] = useConnection(loginConnection); + const [, { token }] = useLogin(); return { fetcherOptions: { diff --git a/src/generated/v3/utils.ts b/src/generated/v3/utils.ts index fc9fa5d15..6378cd64c 100644 --- a/src/generated/v3/utils.ts +++ b/src/generated/v3/utils.ts @@ -1,7 +1,6 @@ import ApiSlice, { ApiDataStore, isErrorState, isInProgress, Method, PendingErrorState } from "@/store/apiSlice"; import Log from "@/utils/log"; -import { loginConnection } from "@/connections/Login"; -import { Connection, OptionalProps } from "@/types/connection"; +import { selectLogin } from "@/connections/Login"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; @@ -56,13 +55,6 @@ export function fetchFailed({ const isPending = (method: Method, fullUrl: string) => ApiSlice.apiDataStore.meta.pending[method][fullUrl] != null; -// We might want this utility more generally available. I'm hoping to avoid the need more widely, but I'm not totally -// opposed to this living in utils/ if we end up having a legitimate need for it. -const selectConnection = ( - connection: Connection, - props: P | Record = {} -) => connection.selector(ApiSlice.apiDataStore, props); - async function dispatchRequest(url: string, requestInit: RequestInit) { const actionPayload = { url, method: requestInit.method as Method }; ApiSlice.fetchStarting(actionPayload); @@ -138,7 +130,7 @@ export function serviceFetch< // store, which means that the next connections that kick off right away don't have access to // the token through the getAccessToken method. So, we grab it from the store instead, which is // more reliable in this case. - const { token } = selectConnection(loginConnection); + const { token } = selectLogin(); if (!requestHeaders?.Authorization && token != null) { // Always include the JWT access token if we have one. requestHeaders.Authorization = `Bearer ${token}`; diff --git a/src/hooks/useConnection.ts b/src/hooks/useConnection.ts index 8b31f294b..ee7db446a 100644 --- a/src/hooks/useConnection.ts +++ b/src/hooks/useConnection.ts @@ -50,5 +50,5 @@ export function useConnection( - connections: [Connection, Connection], - props?: P1 & P2 -): readonly [boolean, [S1, S2]]; -export function useConnections< - S1, - S2, - S3, - P1 extends OptionalProps, - P2 extends OptionalProps, - P3 extends OptionalProps ->( - connections: [Connection, Connection, Connection], - props?: P1 & P2 & P3 -): readonly [boolean, [S1, S2, S3]]; -export function useConnections< - S1, - S2, - S3, - S4, - P1 extends OptionalProps, - P2 extends OptionalProps, - P3 extends OptionalProps, - P4 extends OptionalProps ->( - connections: [Connection, Connection, Connection, Connection], - props?: P1 & P2 & P3 & P4 -): readonly [boolean, [S1, S2, S3, S4]]; -export function useConnections< - S1, - S2, - S3, - S4, - S5, - P1 extends OptionalProps, - P2 extends OptionalProps, - P3 extends OptionalProps, - P4 extends OptionalProps, - P5 extends OptionalProps ->( - connections: [Connection, Connection, Connection, Connection, Connection], - props?: P1 & P2 & P3 & P4 & P5 -): readonly [boolean, [S1, S2, S3, S4, S5]]; - -/** - * A convenience hook to depend on multiple connections, and receive a single "loaded" flag for all of them. - */ -export function useConnections( - connections: Connection[], - props: Record = {} -): readonly [boolean, unknown[]] { - const numConnections = useRef(connections.length); - if (numConnections.current !== connections.length) { - // We're violating the rules of hooks by running hooks in a loop below, so let's scream about - // it extra loud if the number of connections changes. - Log.error("NUMBER OF CONNECTIONS CHANGED!", { original: numConnections.current, current: connections.length }); - } - - return connections.reduce( - ([allLoaded, connecteds], connection) => { - const [loaded, connected] = useConnection(connection, props); - return [loaded && allLoaded, [...connecteds, connected]]; - }, - [true, []] as readonly [boolean, unknown[]] - ); -} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 95d0bd9e9..a0e1f6ab8 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -13,8 +13,8 @@ import { Provider as ReduxProvider } from "react-redux"; import Toast from "@/components/elements/Toast/Toast"; import ModalRoot from "@/components/extensive/Modal/ModalRoot"; import MainLayout from "@/components/generic/Layout/MainLayout"; -import { loginConnection } from "@/connections/Login"; -import { myUserConnection } from "@/connections/User"; +import { loadLogin } from "@/connections/Login"; +import { loadMyUser } from "@/connections/User"; import { LoadingProvider } from "@/context/loaderAdmin.provider"; import ModalProvider from "@/context/modal.provider"; import NavbarProvider from "@/context/navbar.provider"; @@ -25,7 +25,6 @@ import ToastProvider from "@/context/toast.provider"; import { getServerSideTranslations, setClientSideTranslations } from "@/i18n"; import { apiSlice } from "@/store/apiSlice"; import { wrapper } from "@/store/store"; -import { loadConnection } from "@/utils/loadConnection"; import Log from "@/utils/log"; import setupYup from "@/yup.locale"; @@ -90,9 +89,9 @@ const _App = ({ Component, ...rest }: AppProps) => { _App.getInitialProps = wrapper.getInitialAppProps(store => async (context: AppContext) => { const authToken = nookies.get(context.ctx).accessToken; - if (authToken != null && (await loadConnection(loginConnection)).token !== authToken) { + if (authToken != null && (await loadLogin()).token !== authToken) { store.dispatch(apiSlice.actions.setInitialAuthToken({ authToken })); - await loadConnection(myUserConnection); + await loadMyUser(); } const ctx = await App.getInitialProps(context); diff --git a/src/pages/auth/login/index.page.tsx b/src/pages/auth/login/index.page.tsx index 9f7fdb55f..3f7ae4213 100644 --- a/src/pages/auth/login/index.page.tsx +++ b/src/pages/auth/login/index.page.tsx @@ -4,9 +4,8 @@ import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import * as yup from "yup"; -import { login, loginConnection } from "@/connections/Login"; +import { login, useLogin } from "@/connections/Login"; import { ToastType, useToastContext } from "@/context/toast.provider"; -import { useConnection } from "@/hooks/useConnection"; import { useSetInviteToken } from "@/hooks/useInviteToken"; import { useValueChanged } from "@/hooks/useValueChanged"; @@ -29,7 +28,7 @@ const LoginPage = () => { useSetInviteToken(); const t = useT(); const router = useRouter(); - const [, { isLoggedIn, isLoggingIn, loginFailed }] = useConnection(loginConnection); + const [, { isLoggedIn, isLoggingIn, loginFailed }] = useLogin(); const { openToast } = useToastContext(); const form = useForm({ resolver: yupResolver(LoginFormDataSchema(t)), diff --git a/src/pages/form/[id]/pitch-select.page.tsx b/src/pages/form/[id]/pitch-select.page.tsx index fdf010633..a3f6c999a 100644 --- a/src/pages/form/[id]/pitch-select.page.tsx +++ b/src/pages/form/[id]/pitch-select.page.tsx @@ -13,10 +13,9 @@ import Form from "@/components/extensive/Form/Form"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { useGetV2FormsUUID, useGetV2ProjectPitches, usePostV2FormsSubmissions } from "@/generated/apiComponents"; import { FormRead } from "@/generated/apiSchemas"; -import { useConnection } from "@/hooks/useConnection"; import { useDate } from "@/hooks/useDate"; const schema = yup.object({ @@ -30,7 +29,7 @@ const FormIntroPage = () => { const t = useT(); const router = useRouter(); const { format } = useDate(); - const [, { organisationId }] = useConnection(myOrganisationConnection); + const [, { organisationId }] = useMyOrg(); const formUUID = router.query.id as string; diff --git a/src/pages/home.page.tsx b/src/pages/home.page.tsx index 20f4b962b..252fabf3c 100644 --- a/src/pages/home.page.tsx +++ b/src/pages/home.page.tsx @@ -14,15 +14,14 @@ import TaskList from "@/components/extensive/TaskList/TaskList"; import { useGetHomeTourItems } from "@/components/extensive/WelcomeTour/useGetHomeTourItems"; import WelcomeTour from "@/components/extensive/WelcomeTour/WelcomeTour"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { useGetV2FundingProgramme } from "@/generated/apiComponents"; -import { useConnection } from "@/hooks/useConnection"; import { useAcceptInvitation } from "@/hooks/useInviteToken"; import { fundingProgrammeToFundingCardProps } from "@/utils/dataTransformation"; const HomePage = () => { const t = useT(); - const [, { organisation, organisationId }] = useConnection(myOrganisationConnection); + const [, { organisation, organisationId }] = useMyOrg(); const route = useRouter(); const tourSteps = useGetHomeTourItems(); useAcceptInvitation(); diff --git a/src/pages/my-projects/index.page.tsx b/src/pages/my-projects/index.page.tsx index 82973b396..02fbc5ac8 100644 --- a/src/pages/my-projects/index.page.tsx +++ b/src/pages/my-projects/index.page.tsx @@ -15,14 +15,13 @@ import PageFooter from "@/components/extensive/PageElements/Footer/PageFooter"; import PageHeader from "@/components/extensive/PageElements/Header/PageHeader"; import PageSection from "@/components/extensive/PageElements/Section/PageSection"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { ToastType, useToastContext } from "@/context/toast.provider"; import { GetV2MyProjectsResponse, useDeleteV2ProjectsUUID, useGetV2MyProjects } from "@/generated/apiComponents"; -import { useConnection } from "@/hooks/useConnection"; const MyProjectsPage = () => { const t = useT(); - const [, { organisation }] = useConnection(myOrganisationConnection); + const [, { organisation }] = useMyOrg(); const { openToast } = useToastContext(); const { data: projectsData, isLoading, refetch } = useGetV2MyProjects<{ data: GetV2MyProjectsResponse }>({}); diff --git a/src/pages/opportunities/index.page.tsx b/src/pages/opportunities/index.page.tsx index 658d127ae..6b3fa5dc4 100644 --- a/src/pages/opportunities/index.page.tsx +++ b/src/pages/opportunities/index.page.tsx @@ -18,15 +18,14 @@ import PageSection from "@/components/extensive/PageElements/Section/PageSection import ApplicationsTable from "@/components/extensive/Tables/ApplicationsTable"; import PitchesTable from "@/components/extensive/Tables/PitchesTable"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { useGetV2FundingProgramme, useGetV2MyApplications } from "@/generated/apiComponents"; -import { useConnection } from "@/hooks/useConnection"; import { fundingProgrammeToFundingCardProps } from "@/utils/dataTransformation"; const OpportunitiesPage = () => { const t = useT(); const route = useRouter(); - const [, { organisation, organisationId }] = useConnection(myOrganisationConnection); + const [, { organisation, organisationId }] = useMyOrg(); const [pitchesCount, setPitchesCount] = useState(); const { data: fundingProgrammes, isLoading: loadingFundingProgrammes } = useGetV2FundingProgramme({ diff --git a/src/pages/organization/create/index.page.tsx b/src/pages/organization/create/index.page.tsx index 87010aab5..1b3573abc 100644 --- a/src/pages/organization/create/index.page.tsx +++ b/src/pages/organization/create/index.page.tsx @@ -7,7 +7,7 @@ import { ModalId } from "@/components/extensive/Modal/ModalConst"; import WizardForm from "@/components/extensive/WizardForm"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { useModalContext } from "@/context/modal.provider"; import { useDeleteV2OrganisationsRetractMyDraft, @@ -16,7 +16,6 @@ import { usePutV2OrganisationsUUID } from "@/generated/apiComponents"; import { V2OrganisationRead } from "@/generated/apiSchemas"; -import { useConnection } from "@/hooks/useConnection"; import { useNormalizedFormDefaultValue } from "@/hooks/useGetCustomFormSteps/useGetCustomFormSteps"; import { getSteps } from "./getCreateOrganisationSteps"; @@ -24,7 +23,7 @@ import { getSteps } from "./getCreateOrganisationSteps"; const CreateOrganisationForm = () => { const t = useT(); const router = useRouter(); - const [, { organisationId }] = useConnection(myOrganisationConnection); + const [, { organisationId }] = useMyOrg(); const { openModal, closeModal } = useModalContext(); const queryClient = useQueryClient(); diff --git a/src/pages/organization/status/pending.page.tsx b/src/pages/organization/status/pending.page.tsx index cc0b04aa4..145424abb 100644 --- a/src/pages/organization/status/pending.page.tsx +++ b/src/pages/organization/status/pending.page.tsx @@ -5,12 +5,11 @@ import HandsPlantingImage from "public/images/hands-planting.webp"; import Text from "@/components/elements/Text/Text"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; -import { myOrganisationConnection } from "@/connections/Organisation"; -import { useConnection } from "@/hooks/useConnection"; +import { useMyOrg } from "@/connections/Organisation"; const OrganizationPendingPage = () => { const t = useT(); - const [, { organisation }] = useConnection(myOrganisationConnection); + const [, { organisation }] = useMyOrg(); return ( diff --git a/src/pages/organization/status/rejected.page.tsx b/src/pages/organization/status/rejected.page.tsx index 2ced19e31..3203ef1b7 100644 --- a/src/pages/organization/status/rejected.page.tsx +++ b/src/pages/organization/status/rejected.page.tsx @@ -6,12 +6,11 @@ import Text from "@/components/elements/Text/Text"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { zendeskSupportLink } from "@/constants/links"; -import { useConnection } from "@/hooks/useConnection"; const OrganizationRejectedPage = () => { - const [, { organisation }] = useConnection(myOrganisationConnection); + const [, { organisation }] = useMyOrg(); const t = useT(); return ( diff --git a/src/types/connection.ts b/src/types/connection.ts index 54612872a..3fc05d7c8 100644 --- a/src/types/connection.ts +++ b/src/types/connection.ts @@ -13,4 +13,4 @@ export type Connection void; }; -export type Connected = readonly [boolean, SelectedType | Record]; +export type Connected = readonly [true, SelectedType] | readonly [false, Record]; diff --git a/src/utils/connectionShortcuts.ts b/src/utils/connectionShortcuts.ts new file mode 100644 index 000000000..49366a9bd --- /dev/null +++ b/src/utils/connectionShortcuts.ts @@ -0,0 +1,30 @@ +import { useConnection } from "@/hooks/useConnection"; +import ApiSlice from "@/store/apiSlice"; +import { Connection, OptionalProps } from "@/types/connection"; +import { loadConnection } from "@/utils/loadConnection"; + +/** + * Generates a hook for using this specific connection. + */ +export function connectionHook(connection: Connection) { + return (props: TProps | Record = {}) => useConnection(connection, props); +} + +/** + * Generates an async loader for this specific connection. Awaiting on the loader will not return + * until the connection is in a valid loaded state. + */ +export function connectionLoader(connection: Connection) { + return (props: TProps | Record = {}) => loadConnection(connection, props); +} + +/** + * Generates a synchronous selector for this specific connection. Ignores loaded state and simply + * returns the current connection state with whatever is currently cached in the store. + * + * Note: Use sparingly! There are very few cases where this type of connection access is actually + * desirable. In almost every case, connectionHook or connectionLoader is what you really want. + */ +export function connectionSelector(connection: Connection) { + return (props: TProps | Record = {}) => connection.selector(ApiSlice.apiDataStore, props); +} From 5610324b6a91a7c88059adc314324239fb6efdb6 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 30 Sep 2024 17:09:38 -0700 Subject: [PATCH 024/102] [TM-1312] Make sure users/me is loaded when the useMyOrg hook is in use. --- src/admin/apiProvider/authProvider.ts | 5 +---- src/connections/Login.ts | 3 ++- src/connections/Organisation.ts | 17 +++++++++------- src/connections/User.ts | 28 ++++++++++++++++----------- src/store/apiSlice.ts | 27 +++++++++++++------------- src/utils/loadConnection.ts | 3 +-- 6 files changed, 45 insertions(+), 38 deletions(-) diff --git a/src/admin/apiProvider/authProvider.ts b/src/admin/apiProvider/authProvider.ts index c08b94e03..c666ce58c 100644 --- a/src/admin/apiProvider/authProvider.ts +++ b/src/admin/apiProvider/authProvider.ts @@ -23,10 +23,7 @@ export const authProvider: AuthProvider = { // remove local credentials logout: async () => { const { isLoggedIn } = await loadLogin(); - if (isLoggedIn) { - logout(); - window.location.replace("/auth/login"); - } + if (isLoggedIn) logout(); }, getIdentity: async () => { diff --git a/src/connections/Login.ts b/src/connections/Login.ts index 537e22b20..bf54ca07a 100644 --- a/src/connections/Login.ts +++ b/src/connections/Login.ts @@ -20,9 +20,10 @@ export const logout = () => { // When we log out, remove all cached API resources so that when we log in again, these resources // are freshly fetched from the BE. ApiSlice.clearApiCache(); + window.location.replace("/auth/login"); }; -const selectFirstLogin = (store: ApiDataStore) => Object.values(store.logins)?.[0]?.attributes; +export const selectFirstLogin = (store: ApiDataStore) => Object.values(store.logins)?.[0]?.attributes; const loginConnection: Connection = { selector: createSelector( diff --git a/src/connections/Organisation.ts b/src/connections/Organisation.ts index 10b1a936d..a4d7971e9 100644 --- a/src/connections/Organisation.ts +++ b/src/connections/Organisation.ts @@ -1,10 +1,10 @@ import { createSelector } from "reselect"; -import { selectMe } from "@/connections/User"; +import { selectMe, useMyUser } from "@/connections/User"; import { OrganisationDto } from "@/generated/v3/userService/userServiceSchemas"; +import { useConnection } from "@/hooks/useConnection"; import { ApiDataStore } from "@/store/apiSlice"; import { Connection } from "@/types/connection"; -import { connectionHook } from "@/utils/connectionShortcuts"; import { selectorCache } from "@/utils/selectorCache"; type OrganisationConnection = { @@ -39,10 +39,6 @@ export const organisationConnection: Connection = { selector: createSelector([selectMe, selectOrganisations], (user, orgs) => { const { id, meta } = user?.relationships?.org?.[0] ?? {}; @@ -55,4 +51,11 @@ const myOrganisationConnection: Connection = { }; }) }; -export const useMyOrg = connectionHook(myOrganisationConnection); +// The "myOrganisationConnection" is only valid once the users/me response has been loaded, so +// this hook depends on the myUserConnection to fetch users/me and then loads the data it needs +// from the store. +export const useMyOrg = () => { + const [loaded] = useMyUser(); + const [, orgShape] = useConnection(myOrganisationConnection); + return [loaded, orgShape]; +}; diff --git a/src/connections/User.ts b/src/connections/User.ts index 86748dfe9..36f65f448 100644 --- a/src/connections/User.ts +++ b/src/connections/User.ts @@ -1,16 +1,19 @@ import { createSelector } from "reselect"; +import { selectFirstLogin } from "@/connections/Login"; import { usersFind, UsersFindVariables } from "@/generated/v3/userService/userServiceComponents"; import { usersFindFetchFailed } from "@/generated/v3/userService/userServicePredicates"; import { UserDto } from "@/generated/v3/userService/userServiceSchemas"; -import { ApiDataStore, Relationships } from "@/store/apiSlice"; +import { ApiDataStore } from "@/store/apiSlice"; import { Connection } from "@/types/connection"; import { connectionHook, connectionLoader } from "@/utils/connectionShortcuts"; type UserConnection = { user?: UserDto; - userRelationships?: Relationships; userLoadFailed: boolean; + + /** Used internally by the connection to determine if an attempt to load users/me should happen or not. */ + isLoggedIn: boolean; }; const selectMeId = (store: ApiDataStore) => store.meta.meUserId; @@ -21,18 +24,21 @@ export const selectMe = createSelector([selectMeId, selectUsers], (meId, users) const FIND_ME: UsersFindVariables = { pathParams: { id: "me" } }; -const myUserConnection: Connection = { - load: ({ user }) => { - if (user == null) usersFind(FIND_ME); +export const myUserConnection: Connection = { + load: ({ isLoggedIn, user }) => { + if (user == null && isLoggedIn) usersFind(FIND_ME); }, - isLoaded: ({ user, userLoadFailed }) => userLoadFailed || user != null, + isLoaded: ({ user, userLoadFailed, isLoggedIn }) => !isLoggedIn || userLoadFailed || user != null, - selector: createSelector([selectMe, usersFindFetchFailed(FIND_ME)], (resource, userLoadFailure) => ({ - user: resource?.attributes, - userRelationships: resource?.relationships, - userLoadFailed: userLoadFailure != null - })) + selector: createSelector( + [selectMe, selectFirstLogin, usersFindFetchFailed(FIND_ME)], + (resource, firstLogin, userLoadFailure) => ({ + user: resource?.attributes, + userLoadFailed: userLoadFailure != null, + isLoggedIn: firstLogin?.token != null + }) + ) }; export const useMyUser = connectionHook(myUserConnection); export const loadMyUser = connectionLoader(myUserConnection); diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index af9fadc86..a63d99d99 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -5,7 +5,6 @@ import { HYDRATE } from "next-redux-wrapper"; import { Store } from "redux"; import { setAccessToken } from "@/admin/apiProvider/utils/token"; -import { usersFind } from "@/generated/v3/userService/userServiceComponents"; import { LoginDto, OrganisationDto, UserDto } from "@/generated/v3/userService/userServiceSchemas"; export type PendingErrorState = { @@ -124,8 +123,6 @@ const clearApiCache = (state: WritableDraft) => { const isLogin = ({ url, method }: { url: string; method: Method }) => url.endsWith("auth/v3/logins") && method === "POST"; -const reloadMe = () => setTimeout(() => usersFind({ pathParams: { id: "me" } }), 0); - export const apiSlice = createSlice({ name: "api", @@ -146,9 +143,6 @@ export const apiSlice = createSlice({ // After a successful login, clear the entire cache; we want all mounted components to // re-fetch their data with the new login credentials. clearApiCache(state); - // TODO: this will no longer be needed once we have connection chaining, as the my org - // connection will force the my user connection to load. - reloadMe(); } else { delete state.meta.pending[method][url]; } @@ -195,20 +189,27 @@ export const apiSlice = createSlice({ extraReducers: builder => { builder.addCase(HYDRATE, (state, action) => { - clearApiCache(state); - - const { payload } = action as unknown as PayloadAction<{ api: ApiDataStore }>; + const { + payload: { api: payloadState } + } = action as unknown as PayloadAction<{ api: ApiDataStore }>; + + if (state.meta.meUserId !== payloadState.meta.meUserId) { + // It's likely the server hasn't loaded as many resources as the client. We should only + // clear out our cached client-side state if the server claims to have a different logged-in + // user state than we do. + clearApiCache(state); + } for (const resource of RESOURCES) { - state[resource] = payload.api[resource] as any; + state[resource] = payloadState[resource] as StoreResourceMap; } for (const method of METHODS) { - state.meta.pending[method] = payload.api.meta.pending[method]; + state.meta.pending[method] = payloadState.meta.pending[method]; } - if (payload.api.meta.meUserId != null) { - state.meta.meUserId = payload.api.meta.meUserId; + if (payloadState.meta.meUserId != null) { + state.meta.meUserId = payloadState.meta.meUserId; } }); } diff --git a/src/utils/loadConnection.ts b/src/utils/loadConnection.ts index f4912829e..08870d80f 100644 --- a/src/utils/loadConnection.ts +++ b/src/utils/loadConnection.ts @@ -11,8 +11,7 @@ export async function loadConnection { const connected = selector(store, props); const loaded = isLoaded == null || isLoaded(connected, props); - // Delay to avoid calling dispatch during store update resolution - if (!loaded && load != null) setTimeout(() => load(connected, props), 0); + if (!loaded && load != null) load(connected, props); return loaded; }; From d5cee9aca6e349cfc13bda715a39cb54f3737fd6 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 7 Oct 2024 10:39:54 -0700 Subject: [PATCH 025/102] [TM-1312] Fix the useConnection test. --- src/hooks/useConnection.test.tsx | 18 +++++++++++------- src/store/store.ts | 4 ++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/hooks/useConnection.test.tsx b/src/hooks/useConnection.test.tsx index ce7e92b60..553412273 100644 --- a/src/hooks/useConnection.test.tsx +++ b/src/hooks/useConnection.test.tsx @@ -1,15 +1,19 @@ import { renderHook } from "@testing-library/react"; -import { ReactNode } from "react"; +import { ReactNode, useMemo } from "react"; import { act } from "react-dom/test-utils"; +import { Provider as ReduxProvider } from "react-redux"; import { createSelector } from "reselect"; -import { LoginResponse } from "@/generated/v3/userService/userServiceSchemas"; +import { AuthLoginResponse } from "@/generated/v3/userService/userServiceComponents"; import { useConnection } from "@/hooks/useConnection"; import ApiSlice, { ApiDataStore, JsonApiResource } from "@/store/apiSlice"; -import StoreProvider from "@/store/StoreProvider"; +import { makeStore } from "@/store/store"; import { Connection } from "@/types/connection"; -const StoreWrapper = ({ children }: { children: ReactNode }) => {children}; +const StoreWrapper = ({ children }: { children: ReactNode }) => { + const store = useMemo(() => makeStore(), []); + return {children}; +}; describe("Test useConnection hook", () => { test("isLoaded", () => { @@ -40,7 +44,7 @@ describe("Test useConnection hook", () => { }); const connection = { selector: createSelector([selector], payloadCreator) - } as Connection<{ login: LoginResponse }>; + } as Connection<{ login: AuthLoginResponse }>; const { result, rerender } = renderHook(() => useConnection(connection), { wrapper: StoreWrapper }); rerender(); @@ -52,7 +56,7 @@ describe("Test useConnection hook", () => { expect(payloadCreator).toHaveBeenCalledTimes(1); const token = "asdfasdfasdf"; - const data = { type: "logins", id: "1", token } as JsonApiResource; + const data = { type: "logins", id: "1", attributes: { token } } as JsonApiResource; act(() => { ApiSlice.fetchSucceeded({ url: "/foo", method: "POST", response: { data } }); }); @@ -60,7 +64,7 @@ describe("Test useConnection hook", () => { // The store has changed so the selector gets called again, and the selector's result has // changed so the payload creator gets called again, and returns the new Login response that // was saved in the store. - expect(result.current[1]).toStrictEqual({ login: data }); + expect(result.current[1]).toStrictEqual({ login: { attributes: { token } } }); expect(selector).toHaveBeenCalledTimes(2); expect(payloadCreator).toHaveBeenCalledTimes(2); diff --git a/src/store/store.ts b/src/store/store.ts index 818ee3bf5..4b9679305 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -9,7 +9,7 @@ export type AppStore = { api: ApiDataStore; }; -const makeStore: MakeStore> = () => { +export const makeStore = () => { const store = configureStore({ reducer: { api: apiSlice.reducer @@ -39,4 +39,4 @@ const makeStore: MakeStore> = () => { return store; }; -export const wrapper = createWrapper>(makeStore); +export const wrapper = createWrapper>(makeStore as MakeStore>); From c10af9b9b38030deeb4a35b0e04907daf3c08ed3 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 7 Oct 2024 11:26:03 -0700 Subject: [PATCH 026/102] [TM-1312] Fix the middleware tests. --- .../v3/userService/userServiceComponents.ts | 5 +- .../v3/userService/userServiceSchemas.ts | 12 +- src/middleware.page.ts | 6 +- src/middleware.test.ts | 146 ++++++------------ 4 files changed, 62 insertions(+), 107 deletions(-) diff --git a/src/generated/v3/userService/userServiceComponents.ts b/src/generated/v3/userService/userServiceComponents.ts index 38fcb11c5..6a60c063d 100644 --- a/src/generated/v3/userService/userServiceComponents.ts +++ b/src/generated/v3/userService/userServiceComponents.ts @@ -55,6 +55,9 @@ export const authLogin = (variables: AuthLoginVariables, signal?: AbortSignal) = }); export type UsersFindPathParams = { + /** + * A valid user id or "me" + */ id: string; }; @@ -117,7 +120,7 @@ export type UsersFindResponse = { */ id?: string; meta?: { - userStatus?: "approved" | "requested" | "rejected"; + userStatus?: "approved" | "requested" | "rejected" | "na"; }; }; }; diff --git a/src/generated/v3/userService/userServiceSchemas.ts b/src/generated/v3/userService/userServiceSchemas.ts index bd957da65..5c8c36617 100644 --- a/src/generated/v3/userService/userServiceSchemas.ts +++ b/src/generated/v3/userService/userServiceSchemas.ts @@ -30,12 +30,12 @@ export type UserFramework = { export type UserDto = { uuid: string; - firstName: string; - lastName: string; + firstName: string | null; + lastName: string | null; /** * Currently just calculated by appending lastName to firstName. */ - fullName: string; + fullName: string | null; primaryRole: string; /** * @example person@foocorp.net @@ -44,12 +44,12 @@ export type UserDto = { /** * @format date-time */ - emailAddressVerifiedAt: string; - locale: string; + emailAddressVerifiedAt: string | null; + locale: string | null; frameworks: UserFramework[]; }; export type OrganisationDto = { status: "draft" | "pending" | "approved" | "rejected"; - name: string; + name: string | null; }; diff --git a/src/middleware.page.ts b/src/middleware.page.ts index 492551c24..28a01f9d5 100644 --- a/src/middleware.page.ts +++ b/src/middleware.page.ts @@ -2,7 +2,7 @@ import * as Sentry from "@sentry/nextjs"; import { NextRequest } from "next/server"; import { isAdmin, UserRole } from "@/admin/apiProvider/utils/user"; -import { UserDto } from "@/generated/v3/userService/userServiceSchemas"; +import { OrganisationDto, UserDto } from "@/generated/v3/userService/userServiceSchemas"; import { resolveUrl } from "@/generated/v3/utils"; import { MiddlewareCacheKey, MiddlewareMatcher } from "@/utils/MiddlewareMatcher"; @@ -51,8 +51,8 @@ export async function middleware(request: NextRequest) { const { id: organisationId, meta: { userStatus } - } = json.data.relationships.org.data; - const organisation = json.included[0]; + } = json.data.relationships?.org?.data ?? { meta: {} }; + const organisation: OrganisationDto | undefined = json.included?.[0]?.attributes; matcher.if( !user?.emailAddressVerifiedAt, diff --git a/src/middleware.test.ts b/src/middleware.test.ts index d6a05dfdb..a178dec33 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -4,18 +4,13 @@ import { NextRequest, NextResponse } from "next/server"; -import * as api from "@/generated/apiComponents"; +import { OrganisationDto, UserDto } from "@/generated/v3/userService/userServiceSchemas"; import { middleware } from "./middleware.page"; //@ts-ignore Headers.prototype.getAll = () => []; //To fix TypeError: this._headers.getAll is not a function -jest.mock("@/generated/apiComponents", () => ({ - __esModule: true, - fetchGetAuthMe: jest.fn() -})); - const domain = "https://localhost:3000"; const getRequest = (url: string, loggedIn?: boolean, cachedUrl?: string) => { @@ -87,20 +82,44 @@ describe("User is not Logged In", () => { }); }); +function mockUsersMe( + userAttributes: Partial, + org: { + attributes?: Partial; + id?: string; + userStatus?: string; + } = {} +) { + jest.spyOn(global, "fetch").mockImplementation(() => + Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + data: { + attributes: userAttributes, + relationships: { + org: { + data: { + id: org.id, + meta: { userStatus: org.userStatus } + } + } + } + }, + included: [{ attributes: org.attributes }] + }) + } as Response) + ); +} + describe("User is Logged In and not verified", () => { - beforeEach(() => { + afterEach(() => { jest.resetAllMocks(); }); it("redirect not verified users to /auth/signup/confirm", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address: "test@example.com", - email_address_verified_at: null - } - }); + mockUsersMe({ emailAddress: "test@example.com" }); await middleware(getRequest("/", true)); await testMultipleRoute(spy, `/auth/signup/confirm?email=test@example.com`); @@ -108,21 +127,13 @@ describe("User is Logged In and not verified", () => { }); describe("User is Logged In and verified", () => { - beforeEach(() => { + afterEach(() => { jest.resetAllMocks(); }); it("redirect routes that start with /organization/create to /organization/create/confirm when org has been approved", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: { - status: "approved" - } - } - }); + mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { attributes: { status: "approved" } }); await middleware(getRequest("/organization/create/test", true)); expect(spy).toBeCalledWith(new URL("/organization/create/confirm", domain)); @@ -133,86 +144,45 @@ describe("User is Logged In and verified", () => { it("redirect any route to /admin when user is an admin", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: null, - role: "admin-super" - } - }); + mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z", primaryRole: "admin-super" }); + await testMultipleRoute(spy, "/admin"); }); it("redirect any route to /organization/assign when org does not exist", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: null - } - }); + mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }); + await testMultipleRoute(spy, "/organization/assign"); }); it("redirect any route to /organization/create when org is a draft", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: { - status: "draft" - } - } - }); + mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { attributes: { status: "draft" } }); + await testMultipleRoute(spy, "/organization/create"); }); it("redirect any route to /organization/status/pending when user is awaiting org approval", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: { - users_status: "requested" - } - } - }); + mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { userStatus: "requested" }); await testMultipleRoute(spy, "/organization/status/pending"); }); it("redirect any route to /organization/status/rejected when user is rejected", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: { - status: "rejected" - } - } - }); + mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { attributes: { status: "rejected" } }); await testMultipleRoute(spy, "/organization/status/rejected"); }); it("redirect /organization to /organization/[org_uuid]", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: { - status: "approved", - name: "", - uuid: "uuid" - } - } - }); + mockUsersMe( + { emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, + { attributes: { status: "approved" }, id: "uuid" } + ); await middleware(getRequest("/organization", true)); @@ -221,16 +191,7 @@ describe("User is Logged In and verified", () => { it("redirect / to /home", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: { - status: "approved", - name: "" - } - } - }); + mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { attributes: { status: "approved" } }); await middleware(getRequest("/", true)); @@ -239,16 +200,7 @@ describe("User is Logged In and verified", () => { it("redirect routes that startWith /auth to /home", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: { - status: "approved", - name: "" - } - } - }); + mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { attributes: { status: "approved" } }); await middleware(getRequest("/auth", true)); expect(spy).toBeCalledWith(new URL("/home", domain)); From 358fe679dbb46c8c3b3f293b443cbd6e35ab9501 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 7 Oct 2024 14:51:02 -0700 Subject: [PATCH 027/102] [TM-1312] Fix up storybook usage of connections. --- .storybook/preview.js | 9 +--- README.md | 12 ++++++ .../generic/Navbar/Navbar.stories.tsx | 4 +- src/connections/Organisation.ts | 6 +-- src/store/apiSlice.ts | 4 +- src/utils/testStore.tsx | 41 +++++++++++++++++++ 6 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 src/utils/testStore.tsx diff --git a/.storybook/preview.js b/.storybook/preview.js index f9f8b88a2..fe8f4a4e0 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,6 +1,6 @@ import "src/styles/globals.css"; import * as NextImage from "next/image"; -import StoreProvider from "../src/store/StoreProvider"; +import { StoreProvider } from "../src/utils/testStore"; export const parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, @@ -30,12 +30,7 @@ export const decorators = [ (Story, options) => { const { parameters } = options; - let storeProviderProps = {}; - if (parameters.storeProviderProps != null) { - storeProviderProps = parameters.storeProviderProps; - } - - return + return ; }, diff --git a/README.md b/README.md index 617be089b..248d43d36 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,18 @@ Connections are a **declarative** way for components to get access to the data f layer that they need. This system is under development, and the current documentation about it is [available in Confluence](https://gfw.atlassian.net/wiki/spaces/TerraMatch/pages/1423147024/Connections) +Note for Storybook: Because multiple storybook components can be on the page at the same time that each +have their own copy of the redux store, the Connection utilities `loadConnection` (typically used +via `connectionLoaded` in `connectionShortcuts.ts`) and `connectionSelector` will not work as expected +in storybook stories. This is because those utilities rely on `ApiSlice.redux` and `ApiSlice.apiDataStore`, +and in the case of storybook, those will end up with only the redux store from the last component on the +page. Regular connection use through `useConnection` will work because it gets the store from the +Provider in the redux component tree in that case. + +When building storybook stories for components that rely on connections via `useConnection`, make sure +that the story is provided with a store that has all dependent data already loaded. See `testStore.tsx`'s +`buildStore` builder, and `Navbar.stories.tsx` for example usage. + ## Translation ([Transifex Native SDK](https://developers.transifex.com/docs/native)). Transifex native sdk provides a simple solution for internationalization. diff --git a/src/components/generic/Navbar/Navbar.stories.tsx b/src/components/generic/Navbar/Navbar.stories.tsx index 4e9dbaeb5..148b60ef1 100644 --- a/src/components/generic/Navbar/Navbar.stories.tsx +++ b/src/components/generic/Navbar/Navbar.stories.tsx @@ -1,6 +1,8 @@ import { Meta, StoryObj } from "@storybook/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { buildStore } from "@/utils/testStore"; + import Component from "./Navbar"; const meta: Meta = { @@ -14,7 +16,7 @@ const client = new QueryClient(); export const LoggedIn: Story = { parameters: { - storeProviderProps: { authToken: "fakeauthtoken" } + storeBuilder: buildStore().addLogin("fakeauthtoken") }, decorators: [ Story => ( diff --git a/src/connections/Organisation.ts b/src/connections/Organisation.ts index a4d7971e9..86fb42649 100644 --- a/src/connections/Organisation.ts +++ b/src/connections/Organisation.ts @@ -4,7 +4,7 @@ import { selectMe, useMyUser } from "@/connections/User"; import { OrganisationDto } from "@/generated/v3/userService/userServiceSchemas"; import { useConnection } from "@/hooks/useConnection"; import { ApiDataStore } from "@/store/apiSlice"; -import { Connection } from "@/types/connection"; +import { Connected, Connection } from "@/types/connection"; import { selectorCache } from "@/utils/selectorCache"; type OrganisationConnection = { @@ -54,8 +54,8 @@ const myOrganisationConnection: Connection = { // The "myOrganisationConnection" is only valid once the users/me response has been loaded, so // this hook depends on the myUserConnection to fetch users/me and then loads the data it needs // from the store. -export const useMyOrg = () => { +export const useMyOrg = (): Connected => { const [loaded] = useMyUser(); const [, orgShape] = useConnection(myOrganisationConnection); - return [loaded, orgShape]; + return loaded ? [true, orgShape] : [false, {}]; }; diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index a63d99d99..fa6a2ced8 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -83,7 +83,7 @@ export type ApiDataStore = ApiResources & { }; }; -const initialState = { +export const INITIAL_STATE = { ...RESOURCES.reduce((acc: Partial, resource) => { acc[resource] = {}; return acc; @@ -126,7 +126,7 @@ const isLogin = ({ url, method }: { url: string; method: Method }) => export const apiSlice = createSlice({ name: "api", - initialState, + initialState: INITIAL_STATE, reducers: { apiFetchStarting: (state, action: PayloadAction) => { diff --git a/src/utils/testStore.tsx b/src/utils/testStore.tsx new file mode 100644 index 000000000..553219819 --- /dev/null +++ b/src/utils/testStore.tsx @@ -0,0 +1,41 @@ +import { cloneDeep } from "lodash"; +import { HYDRATE } from "next-redux-wrapper"; +import { ReactNode, useMemo } from "react"; +import { Provider as ReduxProvider } from "react-redux"; + +import { INITIAL_STATE } from "@/store/apiSlice"; +import { makeStore } from "@/store/store"; + +class StoreBuilder { + store = cloneDeep(INITIAL_STATE); + + addLogin(token: string) { + this.store.logins[1] = { attributes: { token } }; + return this; + } +} + +export const StoreProvider = ({ storeBuilder, children }: { storeBuilder?: StoreBuilder; children: ReactNode }) => { + // We avoid using wrapper.useWrappedStore here so that different storybook components on the same page + // can have different instances of the redux store. This is a little wonky because anything that + // uses ApiSlice.store directly is going to get the last store created every time, including anything + // that uses connection loads or selectors from connectionShortcuts. However, storybook stories + // should be providing a store that has everything that component needs already loaded, and the + // components only use useConnection, so this will at least work for the expected normal case. + const store = useMemo( + () => { + const store = makeStore(); + const initialState = storeBuilder == null ? undefined : { api: storeBuilder.store }; + if (initialState != null) { + store.dispatch({ type: HYDRATE, payload: initialState }); + } + + return store; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + return {children}; +}; + +export const buildStore = () => new StoreBuilder(); From 1bb94de7ee9ffa86d897ef64bb2a38cc9638f060 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Oct 2024 18:04:11 +0000 Subject: [PATCH 028/102] Bump markdown-to-jsx from 7.2.0 to 7.5.0 Bumps [markdown-to-jsx](https://github.com/quantizor/markdown-to-jsx) from 7.2.0 to 7.5.0. - [Release notes](https://github.com/quantizor/markdown-to-jsx/releases) - [Changelog](https://github.com/quantizor/markdown-to-jsx/blob/main/CHANGELOG.md) - [Commits](https://github.com/quantizor/markdown-to-jsx/compare/v7.2.0...v7.5.0) --- updated-dependencies: - dependency-name: markdown-to-jsx dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 5f755a7a5..d7ba39a10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10999,9 +10999,9 @@ markdown-table@^3.0.0: integrity sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw== markdown-to-jsx@^7.1.8: - version "7.2.0" - resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.2.0.tgz#e7b46b65955f6a04d48a753acd55874a14bdda4b" - integrity sha512-3l4/Bigjm4bEqjCR6Xr+d4DtM1X6vvtGsMGSjJYyep8RjjIvcWtrXBS8Wbfe1/P+atKNMccpsraESIaWVplzVg== + version "7.5.0" + resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-7.5.0.tgz#42ece0c71e842560a7d8bd9f81e7a34515c72150" + integrity sha512-RrBNcMHiFPcz/iqIj0n3wclzHXjwS7mzjBNWecKKVhNTIxQepIix6Il/wZCn2Cg5Y1ow2Qi84+eJrryFRWBEWw== match-sorter@^6.0.2: version "6.3.1" From 7ff5f394e9660852b2b0036056d46b23372d77a9 Mon Sep 17 00:00:00 2001 From: Jorge Monroy Date: Mon, 21 Oct 2024 10:51:17 -0400 Subject: [PATCH 029/102] Feat/tm 1343 popup centroids (#571) * [TM-1343] set initbasemap according to page * [TM-1343] add popup for centroids * [TM-1343] edit style in popup map dashboard * [TM-1343] add centroid only one popup at the time --------- Co-authored-by: Dotty --- src/components/elements/Map-mapbox/Map.tsx | 6 +- .../Map-mapbox/components/DashboardPopup.tsx | 56 ++++++++++++++----- .../elements/Map-mapbox/hooks/useMap.ts | 7 ++- src/components/elements/Map-mapbox/utils.ts | 48 +++++++++------- .../dashboard/components/TooltipGridMap.tsx | 6 +- src/pages/dashboard/hooks/usePopupsData.ts | 19 +++++++ 6 files changed, 100 insertions(+), 42 deletions(-) create mode 100644 src/pages/dashboard/hooks/usePopupsData.ts diff --git a/src/components/elements/Map-mapbox/Map.tsx b/src/components/elements/Map-mapbox/Map.tsx index c54e54b5d..2af322bc0 100644 --- a/src/components/elements/Map-mapbox/Map.tsx +++ b/src/components/elements/Map-mapbox/Map.tsx @@ -156,7 +156,7 @@ export const MapContainer = ({ }: MapProps) => { const [showMediaPopups, setShowMediaPopups] = useState(true); const [viewImages, setViewImages] = useState(false); - const [currentStyle, setCurrentStyle] = useState(MapStyle.Satellite); + const [currentStyle, setCurrentStyle] = useState(isDashboard ? MapStyle.Street : MapStyle.Satellite); const { polygonsData, bbox, setPolygonFromMap, polygonFromMap, sitePolygonData } = props; const context = useSitePolygonData(); const contextMapArea = useMapAreaContext(); @@ -190,7 +190,7 @@ export const MapContainer = ({ mapFunctions; useEffect(() => { - initMap(); + initMap(isDashboard); return () => { if (map.current) { setStyleLoaded(false); @@ -207,7 +207,7 @@ export const MapContainer = ({ } }, [map, location]); useEffect(() => { - if (map?.current && isDashboard && styleLoaded && map.current.isStyleLoaded()) { + if (map?.current && isDashboard && map.current.isStyleLoaded()) { const layerCountry = layersList.find(layer => layer.name === LAYERS_NAMES.WORLD_COUNTRIES); if (layerCountry) { addSourceToLayer(layerCountry, map.current, undefined); diff --git a/src/components/elements/Map-mapbox/components/DashboardPopup.tsx b/src/components/elements/Map-mapbox/components/DashboardPopup.tsx index edb2d50bf..a43c6148e 100644 --- a/src/components/elements/Map-mapbox/components/DashboardPopup.tsx +++ b/src/components/elements/Map-mapbox/components/DashboardPopup.tsx @@ -1,23 +1,32 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useEffect, useState } from "react"; -import { fetchGetV2DashboardTotalSectionHeaderCountry } from "@/generated/apiComponents"; +import { LAYERS_NAMES } from "@/constants/layers"; +import { + fetchGetV2DashboardProjectDataUuid, + fetchGetV2DashboardTotalSectionHeaderCountry +} from "@/generated/apiComponents"; import TooltipGridMap from "@/pages/dashboard/components/TooltipGridMap"; import { createQueryParams } from "@/utils/dashboardUtils"; const client = new QueryClient(); + type Item = { id: string; title: string; value: string; }; + export const DashboardPopup = (event: any) => { const isoCountry = event?.feature?.properties?.iso; - const countryName = event?.feature?.properties?.country; - const { addPopupToMap } = event; + const projectUuid = event?.feature?.properties?.uuid; + const { addPopupToMap, layerName } = event; + const [items, setItems] = useState([]); + const [label, setLabel] = useState(event?.feature?.properties?.country); + useEffect(() => { - async function fetchData() { + async function fetchCountryData() { const parsedFilters = { programmes: [], country: isoCountry, @@ -25,7 +34,7 @@ export const DashboardPopup = (event: any) => { landscapes: [] }; const queryParams: any = createQueryParams(parsedFilters); - const response: any = await fetchGetV2DashboardTotalSectionHeaderCountry({ queryParams: queryParams }); + const response: any = await fetchGetV2DashboardTotalSectionHeaderCountry({ queryParams }); if (response) { const parsedItems = [ { @@ -55,15 +64,34 @@ export const DashboardPopup = (event: any) => { console.error("No data returned from the API"); } } - fetchData(); - }, [isoCountry]); + + async function fetchProjectData() { + const response: any = await fetchGetV2DashboardProjectDataUuid({ pathParams: { uuid: projectUuid } }); + if (response) { + const filteredItems = response.data + .filter((item: any) => item.key !== "project_name") + .map((item: any) => ({ + id: item.key, + title: item.title, + value: item.value + })); + + const projectLabel = response.data.find((item: any) => item.key === "project_name")?.value; + setLabel(projectLabel); + setItems(filteredItems); + addPopupToMap(); + } + } + + if (isoCountry && layerName === LAYERS_NAMES.WORLD_COUNTRIES) { + fetchCountryData(); + } else if (projectUuid && layerName === LAYERS_NAMES.CENTROIDS) { + fetchProjectData(); + } + }, [isoCountry, layerName, projectUuid]); return ( - <> - {items && ( - - - - )} - + + + ); }; diff --git a/src/components/elements/Map-mapbox/hooks/useMap.ts b/src/components/elements/Map-mapbox/hooks/useMap.ts index 1d04c9215..0b2b9af23 100644 --- a/src/components/elements/Map-mapbox/hooks/useMap.ts +++ b/src/components/elements/Map-mapbox/hooks/useMap.ts @@ -7,9 +7,9 @@ import { useMapAreaContext } from "@/context/mapArea.provider"; import { FeatureCollection } from "../GeoJSON"; import type { ControlType } from "../Map.d"; +import { MapStyle } from "../MapControls/types"; import { addFilterOfPolygonsData, convertToGeoJSON } from "../utils"; -const MAP_STYLE = "mapbox://styles/terramatch/clv3bkxut01y301pk317z5afu"; const INITIAL_ZOOM = 2.5; const MAPBOX_TOKEN = process.env.REACT_APP_MAPBOX_TOKEN || @@ -41,12 +41,13 @@ export const useMap = (onSave?: (geojson: any, record: any) => void) => { onSave?.(geojson, record); }; - const initMap = () => { + const initMap = (isDashboard?: string) => { if (map.current) return; + const mapStyle = isDashboard ? MapStyle.Street : MapStyle.Satellite; map.current = new mapboxgl.Map({ container: mapContainer.current as HTMLDivElement, - style: MAP_STYLE, + style: mapStyle, zoom: zoom, accessToken: MAPBOX_TOKEN }); diff --git a/src/components/elements/Map-mapbox/utils.ts b/src/components/elements/Map-mapbox/utils.ts index 5439950e6..6f32f95fd 100644 --- a/src/components/elements/Map-mapbox/utils.ts +++ b/src/components/elements/Map-mapbox/utils.ts @@ -166,8 +166,7 @@ const handleLayerClick = ( const newPopup = createPopup(lngLat); - const isWorldCountriesLayer = layerName === LAYERS_NAMES.WORLD_COUNTRIES; - + const isFetchdataLayer = layerName === LAYERS_NAMES.WORLD_COUNTRIES || layerName === LAYERS_NAMES.CENTROIDS; const commonProps: PopupComponentProps = { feature, popup: newPopup, @@ -178,9 +177,11 @@ const handleLayerClick = ( setEditPolygon }; - if (isWorldCountriesLayer) { - const addPopupToMap = () => newPopup.addTo(map); - root.render(createElement(PopupComponent, { ...commonProps, addPopupToMap })); + if (isFetchdataLayer) { + const addPopupToMap = () => { + newPopup.addTo(map); + }; + root.render(createElement(PopupComponent, { ...commonProps, addPopupToMap, layerName })); } else { newPopup.addTo(map); root.render(createElement(PopupComponent, commonProps)); @@ -408,24 +409,31 @@ export const addPopupToLayer = ( let layers = map.getStyle().layers; let targetLayers = layers.filter(layer => layer.id.startsWith(name)); + if (name === LAYERS_NAMES.CENTROIDS) { + targetLayers = [targetLayers[0]]; + } targetLayers.forEach(targetLayer => { map.on("click", targetLayer.id, (e: any) => { const currentMode = draw?.getMode(); - if (currentMode === "draw_polygon" || currentMode === "draw_line_string") { - return; - } else { - handleLayerClick( - e, - popupComponent, - map, - setPolygonFromMap, - sitePolygonData, - type, - editPolygon, - setEditPolygon, - name - ); - } + if (currentMode === "draw_polygon" || currentMode === "draw_line_string") return; + + const zoomLevel = map.getZoom(); + + if (name === LAYERS_NAMES.WORLD_COUNTRIES && zoomLevel > 4.5) return; + + if (name === LAYERS_NAMES.CENTROIDS && zoomLevel <= 4.5) return; + + handleLayerClick( + e, + popupComponent, + map, + setPolygonFromMap, + sitePolygonData, + type, + editPolygon, + setEditPolygon, + name + ); }); }); } diff --git a/src/pages/dashboard/components/TooltipGridMap.tsx b/src/pages/dashboard/components/TooltipGridMap.tsx index 7e31948f5..a23e71722 100644 --- a/src/pages/dashboard/components/TooltipGridMap.tsx +++ b/src/pages/dashboard/components/TooltipGridMap.tsx @@ -25,10 +25,12 @@ const TooltipGridMap = (props: TooltipGridProps) => { const { label, learnMore, isoCountry, items } = props; const t = useT(); return ( -
+
- flag + {isoCountry && ( + flag + )} {t(label)} diff --git a/src/pages/dashboard/hooks/usePopupsData.ts b/src/pages/dashboard/hooks/usePopupsData.ts new file mode 100644 index 000000000..4ac209a81 --- /dev/null +++ b/src/pages/dashboard/hooks/usePopupsData.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; + +import { useGetV2DashboardPolygonDataUuid, useGetV2DashboardProjectDataUuid } from "@/generated/apiComponents"; + +const usePolygonData = (uuid: string, typeTooltip: string) => { + const getDataHook = typeTooltip === "point" ? useGetV2DashboardProjectDataUuid : useGetV2DashboardPolygonDataUuid; + + const { data: tooltipData, refetch } = getDataHook({ + pathParams: { uuid } + }); + + useEffect(() => { + refetch(); + }, [uuid, refetch, typeTooltip]); + + return { tooltipData }; +}; + +export default usePolygonData; From 28500c3583ac0f6a46f80dbac79910ab221728ad Mon Sep 17 00:00:00 2001 From: Limber Mamani <154026979+LimberHope@users.noreply.github.com> Date: Tue, 22 Oct 2024 13:36:10 -0400 Subject: [PATCH 030/102] [TM-1378] call data for project list view table (#574) --- src/context/dashboard.provider.tsx | 11 +- .../dashboard/components/HeaderDashboard.tsx | 115 ++++++++---------- src/pages/dashboard/hooks/useDashboardData.ts | 10 +- src/pages/dashboard/index.page.tsx | 4 +- .../dashboard/project-list/index.page.tsx | 44 ++++--- 5 files changed, 97 insertions(+), 87 deletions(-) diff --git a/src/context/dashboard.provider.tsx b/src/context/dashboard.provider.tsx index 31ed7aa15..c9945d184 100644 --- a/src/context/dashboard.provider.tsx +++ b/src/context/dashboard.provider.tsx @@ -23,6 +23,8 @@ type DashboardType = { organizations: string[]; }> >; + searchTerm: string; + setSearchTerm: React.Dispatch>; }; const defaultValues: DashboardType = { filters: { @@ -38,15 +40,20 @@ const defaultValues: DashboardType = { }, organizations: [] }, - setFilters: () => {} + setFilters: () => {}, + searchTerm: "", + setSearchTerm: () => {} }; const DashboardContext = createContext(defaultValues); export const DashboardProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [filters, setFilters] = React.useState(defaultValues.filters); + const [searchTerm, setSearchTerm] = React.useState(""); const contextValue: DashboardType = { filters, - setFilters + setFilters, + searchTerm, + setSearchTerm }; return {children}; }; diff --git a/src/pages/dashboard/components/HeaderDashboard.tsx b/src/pages/dashboard/components/HeaderDashboard.tsx index e2de5fd62..90d636eab 100644 --- a/src/pages/dashboard/components/HeaderDashboard.tsx +++ b/src/pages/dashboard/components/HeaderDashboard.tsx @@ -15,6 +15,7 @@ import { useDashboardContext } from "@/context/dashboard.provider"; import { useGetV2DashboardFrameworks } from "@/generated/apiComponents"; import { Option, OptionValue } from "@/types/common"; +import { useDashboardData } from "../hooks/useDashboardData"; import BlurContainer from "./BlurContainer"; interface HeaderDashboardProps { @@ -31,60 +32,28 @@ const HeaderDashboard = (props: HeaderDashboardProps) => { const [programmeOptions, setProgrammeOptions] = useState([]); const t = useT(); const router = useRouter(); + const { filters, setFilters, setSearchTerm } = useDashboardContext(); + const { activeProjects } = useDashboardData(filters); - const optionMenu = [ - { - id: "1", - country: "Angola", - organization: "Annette Ward (3SC)", - project: "Goshen Global Vision", - programme: "TerraFund Top100" - }, - { - id: "2", - country: "Kenya", - organization: "Annette Ward (3SC)", - project: "Goshen Global Vision", - programme: "TerraFund Top100" - }, - { - id: "3", - country: "Ghana", - organization: "Annette Ward (3SC)", - project: "Goshen Global Vision", - programme: "TerraFund Top100" - }, - { - id: "4", - country: "Congo", - organization: "Annette Ward (3SC)", - project: "Goshen Global Vision", - programme: "TerraFund Top100" - }, - { - id: "5", - country: "Central African Republic", - organization: "Annette Ward (3SC)", - project: "Goshen Global Vision", - programme: "TerraFund Top100" - }, - { - id: "6", - country: "Cameroon", - organization: "Annette Ward (3SC)", - project: "Goshen Global Vision", - programme: "TerraFund Top100" - }, - { - id: "7", - - country: "Åland Islands", - organization: "Annette Ward (3SC)", - project: "Goshen Global Vision", - programme: "TerraFund Top100" - } - ]; - const { filters, setFilters } = useDashboardContext(); + const optionMenu = activeProjects + ? activeProjects?.map( + ( + item: { + project_country: string; + organisation: string; + name: string; + programme: string; + }, + index: number + ) => ({ + id: index, + country: item?.project_country, + organization: item?.organisation, + project: item?.name, + programme: item?.programme + }) + ) + : []; const organizationOptions = [ { @@ -335,22 +304,34 @@ const HeaderDashboard = (props: HeaderDashboardProps) => { ({ - id: option.id, - render: () => ( - - - {t(option.country)}, {t(option.organization)},  - - - {t(option.project)}, {t(option.programme)} - - - ) - }))} + menu={optionMenu.map( + (option: { + id: number; + country: string; + organization: string; + project: string; + programme: string; + }) => ({ + id: option.id, + render: () => ( + + + {t(option.country)}, {t(option.organization)},  + + + {t(option.project)}, {t(option.programme)} + + + ) + }) + )} > - {}} placeholder="Search" variant={FILTER_SEARCH_BOX_AIRTABLE} /> + setSearchTerm(e)} + placeholder="Search" + variant={FILTER_SEARCH_BOX_AIRTABLE} + /> diff --git a/src/pages/dashboard/hooks/useDashboardData.ts b/src/pages/dashboard/hooks/useDashboardData.ts index ed82e75ba..dc6e1e8b2 100644 --- a/src/pages/dashboard/hooks/useDashboardData.ts +++ b/src/pages/dashboard/hooks/useDashboardData.ts @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; +import { useDashboardContext } from "@/context/dashboard.provider"; import { useLoading } from "@/context/loaderAdmin.provider"; import { useGetV2DashboardActiveCountries, @@ -82,9 +83,14 @@ export const useDashboardData = (filters: any) => { { enabled: !!filters } ); + const { searchTerm } = useDashboardContext(); const { data: activeProjects } = useGetV2DashboardActiveProjects( { queryParams: queryParams }, - { enabled: !!filters } + { enabled: !!searchTerm || !!filters } + ); + + const filteredProjects = activeProjects?.data.filter((project: { name: string | null }) => + project?.name?.toLowerCase().includes(searchTerm?.toLowerCase()) ); const { data: dashboardRestorationGoalData } = @@ -141,7 +147,7 @@ export const useDashboardData = (filters: any) => { topProject, refetchTotalSectionHeader, activeCountries, - activeProjects, + activeProjects: filteredProjects, centroidsDataProjects: centroidsDataProjects?.data, listViewProjects }; diff --git a/src/pages/dashboard/index.page.tsx b/src/pages/dashboard/index.page.tsx index ee687f940..496876dc2 100644 --- a/src/pages/dashboard/index.page.tsx +++ b/src/pages/dashboard/index.page.tsx @@ -153,8 +153,8 @@ const Dashboard = () => { ) : []; - const DATA_ACTIVE_COUNTRY = activeProjects?.data - ? activeProjects.data.map( + const DATA_ACTIVE_COUNTRY = activeProjects + ? activeProjects?.map( (item: { uuid: string; name: string; diff --git a/src/pages/dashboard/project-list/index.page.tsx b/src/pages/dashboard/project-list/index.page.tsx index 8d81fc260..f0a30c149 100644 --- a/src/pages/dashboard/project-list/index.page.tsx +++ b/src/pages/dashboard/project-list/index.page.tsx @@ -2,7 +2,9 @@ import Table from "@/components/elements/Table/Table"; import { VARIANT_TABLE_DASHBOARD } from "@/components/elements/Table/TableVariants"; import Text from "@/components/elements/Text/Text"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; -import { useGetV2DashboardCountries } from "@/generated/apiComponents"; +import { useDashboardContext } from "@/context/dashboard.provider"; + +import { useDashboardData } from "../hooks/useDashboardData"; export interface DashboardTableDataProps { label: string; @@ -83,20 +85,33 @@ const ProjectList = () => { } ]; - const { data: dashboardCountries } = useGetV2DashboardCountries({ - queryParams: {} - }); + const { filters } = useDashboardContext(); + + const { activeProjects } = useDashboardData(filters); - const DATA_TABLE_PROJECT_LIST = dashboardCountries - ? dashboardCountries.data.map((country: any) => ({ - project: "Annette Ward (3SC)", - organization: "Goshen Global Vision", - programme: "TerraFund Top100", - country: { label: country.data.label, image: country.data.icon }, - treesPlanted: "12,000,000", - restorationHectares: "15,700", - jobsCreated: "9,000,000" - })) + const DATA_TABLE_PROJECT_LIST = activeProjects + ? activeProjects?.map( + (item: { + uuid: string; + name: string; + organisation: string; + programme: string; + country_slug: string; + project_country: string; + trees_under_restoration: number; + hectares_under_restoration: number; + jobs_created: number; + }) => ({ + uuid: item.uuid, + project: item?.name, + organization: item?.organisation, + programme: item?.programme, + country: { label: item?.project_country, image: `/flags/${item?.country_slug.toLowerCase()}.svg` }, + treesPlanted: item.trees_under_restoration.toLocaleString(), + restorationHectares: item.hectares_under_restoration.toLocaleString(), + jobsCreated: item.jobs_created.toLocaleString() + }) + ) : []; return ( @@ -108,6 +123,7 @@ const ProjectList = () => { classNameWrapper="max-h-[calc(100%_-_4rem)] h-[calc(100%_-_4rem)] !px-0" hasPagination={true} invertSelectPagination={true} + initialTableState={{ pagination: { pageSize: 10 } }} />
); From 1e8d5ef0723d80a458163bd512bc656cfc5e4d4b Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Tue, 22 Oct 2024 11:19:48 -0700 Subject: [PATCH 031/102] [TM-1269] convert new uses of console to Log --- .../components/ResourceTabs/GalleryTab/GalleryTab.tsx | 3 ++- .../elements/Inputs/FileInput/FilePreviewTable.tsx | 5 +++-- src/components/elements/Map-mapbox/Map.tsx | 8 ++++---- .../elements/Map-mapbox/components/DashboardPopup.tsx | 3 ++- src/components/extensive/Modal/ModalAddImages.tsx | 3 ++- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/admin/components/ResourceTabs/GalleryTab/GalleryTab.tsx b/src/admin/components/ResourceTabs/GalleryTab/GalleryTab.tsx index 7e32e558d..2fbac0fec 100644 --- a/src/admin/components/ResourceTabs/GalleryTab/GalleryTab.tsx +++ b/src/admin/components/ResourceTabs/GalleryTab/GalleryTab.tsx @@ -14,6 +14,7 @@ import { useModalContext } from "@/context/modal.provider"; import { useDeleteV2FilesUUID, useGetV2MODELUUIDFiles } from "@/generated/apiComponents"; import { getCurrentPathEntity } from "@/helpers/entity"; import { EntityName, FileType } from "@/types/common"; +import Log from "@/utils/log"; interface IProps extends Omit { label?: string; @@ -98,7 +99,7 @@ const GalleryTab: FC = ({ label, entity, ...rest }) => { collection="media" entityData={ctx?.record} setErrorMessage={message => { - console.error(message); + Log.error(message); }} /> ); diff --git a/src/components/elements/Inputs/FileInput/FilePreviewTable.tsx b/src/components/elements/Inputs/FileInput/FilePreviewTable.tsx index 56f216d6d..37c5baa3e 100644 --- a/src/components/elements/Inputs/FileInput/FilePreviewTable.tsx +++ b/src/components/elements/Inputs/FileInput/FilePreviewTable.tsx @@ -11,6 +11,7 @@ import { useLoading } from "@/context/loaderAdmin.provider"; import { useModalContext } from "@/context/modal.provider"; import { usePatchV2MediaProjectProjectMediaUuid, usePatchV2MediaUuid } from "@/generated/apiComponents"; import { UploadedFile } from "@/types/common"; +import Log from "@/utils/log"; import Menu from "../../Menu/Menu"; import Table from "../../Table/Table"; @@ -89,7 +90,7 @@ const FilePreviewTable = ({ items, onDelete, updateFile, entityData }: FilePrevi }); } } catch (error) { - console.error("Error updating cover status:", error); + Log.error("Error updating cover status:", error); } finally { hideLoader(); } @@ -104,7 +105,7 @@ const FilePreviewTable = ({ items, onDelete, updateFile, entityData }: FilePrevi }); updateFile?.({ ...item, is_public: checked }); } catch (error) { - console.error("Error updating public status:", error); + Log.error("Error updating public status:", error); } finally { hideLoader(); } diff --git a/src/components/elements/Map-mapbox/Map.tsx b/src/components/elements/Map-mapbox/Map.tsx index 2af322bc0..66950b705 100644 --- a/src/components/elements/Map-mapbox/Map.tsx +++ b/src/components/elements/Map-mapbox/Map.tsx @@ -4,8 +4,7 @@ import { useT } from "@transifex/react"; import _ from "lodash"; import mapboxgl, { LngLat } from "mapbox-gl"; import { useRouter } from "next/router"; -import React, { useEffect } from "react"; -import { DetailedHTMLProps, HTMLAttributes, useState } from "react"; +import React, { DetailedHTMLProps, HTMLAttributes, useEffect, useState } from "react"; import { When } from "react-if"; import { twMerge } from "tailwind-merge"; import { ValidationError } from "yup"; @@ -34,6 +33,7 @@ import { usePutV2TerrafundPolygonUuid } from "@/generated/apiComponents"; import { DashboardGetProjectsData, SitePolygonsDataResponse } from "@/generated/apiSchemas"; +import Log from "@/utils/log"; import { ImageGalleryItemData } from "../ImageGallery/ImageGalleryItem"; import { AdminPopup } from "./components/AdminPopup"; @@ -346,7 +346,7 @@ export const MapContainer = ({ }); if (!response) { - console.error("No response received from the server."); + Log.error("No response received from the server."); openNotification("error", t("Error!"), t("No response received from the server.")); return; } @@ -365,7 +365,7 @@ export const MapContainer = ({ hideLoader(); openNotification("success", t("Success!"), t("Image downloaded successfully")); } catch (error) { - console.error("Download error:", error); + Log.error("Download error:", error); hideLoader(); } }; diff --git a/src/components/elements/Map-mapbox/components/DashboardPopup.tsx b/src/components/elements/Map-mapbox/components/DashboardPopup.tsx index a43c6148e..6d1b9530d 100644 --- a/src/components/elements/Map-mapbox/components/DashboardPopup.tsx +++ b/src/components/elements/Map-mapbox/components/DashboardPopup.tsx @@ -8,6 +8,7 @@ import { } from "@/generated/apiComponents"; import TooltipGridMap from "@/pages/dashboard/components/TooltipGridMap"; import { createQueryParams } from "@/utils/dashboardUtils"; +import Log from "@/utils/log"; const client = new QueryClient(); @@ -61,7 +62,7 @@ export const DashboardPopup = (event: any) => { setItems(parsedItems); addPopupToMap(); } else { - console.error("No data returned from the API"); + Log.error("No data returned from the API"); } } diff --git a/src/components/extensive/Modal/ModalAddImages.tsx b/src/components/extensive/Modal/ModalAddImages.tsx index 62b7a39f5..10aef1f13 100644 --- a/src/components/extensive/Modal/ModalAddImages.tsx +++ b/src/components/extensive/Modal/ModalAddImages.tsx @@ -15,6 +15,7 @@ import Status from "@/components/elements/Status/Status"; import Text from "@/components/elements/Text/Text"; import { useDeleteV2FilesUUID, usePostV2FileUploadMODELCOLLECTIONUUID } from "@/generated/apiComponents"; import { FileType, UploadedFile } from "@/types/common"; +import Log from "@/utils/log"; import Icon, { IconNames } from "../Icon/Icon"; import { ModalProps } from "./Modal"; @@ -211,7 +212,7 @@ const ModalAddImages: FC = ({ body.append("lng", location.longitude.toString()); } } catch (e) { - console.log(e); + Log.error(e); } uploadFile?.({ From f73605fbafcc1cd37c4528944364a4252eeeb423 Mon Sep 17 00:00:00 2001 From: Limber Mamani <154026979+LimberHope@users.noreply.github.com> Date: Tue, 22 Oct 2024 14:27:25 -0400 Subject: [PATCH 032/102] [TM-1308] fix: restore fileInputProps to file input (#575) --- src/components/elements/Inputs/FileInput/RHFFileInput.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/elements/Inputs/FileInput/RHFFileInput.tsx b/src/components/elements/Inputs/FileInput/RHFFileInput.tsx index 630b2ec4a..85a98cb5a 100644 --- a/src/components/elements/Inputs/FileInput/RHFFileInput.tsx +++ b/src/components/elements/Inputs/FileInput/RHFFileInput.tsx @@ -243,6 +243,7 @@ const RHFFileInput = ({ return ( Date: Tue, 22 Oct 2024 14:36:49 -0400 Subject: [PATCH 033/102] [TM-1369] implement jobs created data and util functions (#569) --- src/constants/dashbordConsts.ts | 8 ++++- .../charts/HorizontalStackedBarChart.tsx | 3 +- src/pages/dashboard/charts/MultiLineChart.tsx | 2 +- src/utils/dashboardUtils.ts | 30 +++++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/constants/dashbordConsts.ts b/src/constants/dashbordConsts.ts index 570fbbc50..46dab989c 100644 --- a/src/constants/dashbordConsts.ts +++ b/src/constants/dashbordConsts.ts @@ -1,6 +1,12 @@ export const CHART_TYPES = { multiLineChart: "multiLineChart", - treesPlantedBarChart: "treesPlantedBarChart" + treesPlantedBarChart: "treesPlantedBarChart", + groupedBarChart: "groupedBarChart" +}; + +export const JOBS_CREATED_CHART_TYPE = { + gender: "gender", + age: "age" }; export const COLORS: Record = { diff --git a/src/pages/dashboard/charts/HorizontalStackedBarChart.tsx b/src/pages/dashboard/charts/HorizontalStackedBarChart.tsx index 5e7bdf702..151e25408 100644 --- a/src/pages/dashboard/charts/HorizontalStackedBarChart.tsx +++ b/src/pages/dashboard/charts/HorizontalStackedBarChart.tsx @@ -6,7 +6,8 @@ const HorizontalStackedBarChart = ({ data = [], className }: { data: any; classN const totalValue = data[0].value; const enterpriseValue = data[1].value; const nonProfitValue = data[2].value; - const remainingValue = totalValue - enterpriseValue - nonProfitValue; + const remainingValue = + enterpriseValue + nonProfitValue > totalValue ? 0 : totalValue - enterpriseValue - nonProfitValue; const chartData = [ { diff --git a/src/pages/dashboard/charts/MultiLineChart.tsx b/src/pages/dashboard/charts/MultiLineChart.tsx index 96bc1264e..c46065433 100644 --- a/src/pages/dashboard/charts/MultiLineChart.tsx +++ b/src/pages/dashboard/charts/MultiLineChart.tsx @@ -62,7 +62,7 @@ const CustomYAxisTick: React.FC = ({ x, y, payload, isAbsoluteData }) => { return ( - + {formattedValue} diff --git a/src/utils/dashboardUtils.ts b/src/utils/dashboardUtils.ts index c23c5200d..8b8adcf13 100644 --- a/src/utils/dashboardUtils.ts +++ b/src/utils/dashboardUtils.ts @@ -8,6 +8,18 @@ type DataPoint = { "Non Profit": number; }; +export interface ChartDataItem { + name: string; + [key: string]: number | string; +} + +export interface GroupedBarChartData { + type: "gender" | "age"; + chartData: ChartDataItem[]; + total: number; + maxValue: number; +} + export const formatNumberUS = (value: number) => value ? (value >= 1000000 ? `${(value / 1000000).toFixed(2)}M` : value.toLocaleString("en-US")) : ""; @@ -120,3 +132,21 @@ export const getRestorationGoalDataForChart = (data: any, isPercentage: boolean) return chartData; }; + +export const formatNumberLocaleString = (value: number): string => { + return value.toLocaleString(); +}; + +export const getPercentage = (value: number, total: number): string => { + return ((value / total) * 100).toFixed(1); +}; + +export const calculateTotals = (data: GroupedBarChartData): { [key: string]: number } => { + return data.chartData.reduce((acc, item) => { + const key1 = data.type === "gender" ? "Women" : "Youth"; + const key2 = data.type === "gender" ? "Men" : "Non-Youth"; + acc[key1] = (acc[key1] || 0) + (item[key1] as number); + acc[key2] = (acc[key2] || 0) + (item[key2] as number); + return acc; + }, {} as { [key: string]: number }); +}; From fb747bf03e2e5f4287cf8da429e9f8b8eb0e01b3 Mon Sep 17 00:00:00 2001 From: cesarLima1 <105736261+cesarLima1@users.noreply.github.com> Date: Tue, 22 Oct 2024 14:56:03 -0400 Subject: [PATCH 034/102] [TM-1370] add grouped bar chart for jobs created (#570) * [TM-1369] implement jobs created data and util functions * [TM-1370] add grouped bar chart for jobs created * [TM-1370] add missing default export for components --- .../dashboard/charts/CustomBarJobsCreated.tsx | 26 +++++++++++ .../charts/CustomLegendJobsCreated.tsx | 33 +++++++++++++ .../charts/CustomTooltipJobsCreated.tsx | 18 ++++++++ .../charts/CustomYAxisTickJobsCreated.tsx | 17 +++++++ .../dashboard/charts/GroupedBarChart.tsx | 38 +++++++++++++++ .../dashboard/components/SecDashboard.tsx | 6 ++- src/pages/dashboard/hooks/useDashboardData.ts | 14 +----- src/pages/dashboard/index.page.tsx | 46 +++++++++++++++---- 8 files changed, 175 insertions(+), 23 deletions(-) create mode 100644 src/pages/dashboard/charts/CustomBarJobsCreated.tsx create mode 100644 src/pages/dashboard/charts/CustomLegendJobsCreated.tsx create mode 100644 src/pages/dashboard/charts/CustomTooltipJobsCreated.tsx create mode 100644 src/pages/dashboard/charts/CustomYAxisTickJobsCreated.tsx create mode 100644 src/pages/dashboard/charts/GroupedBarChart.tsx diff --git a/src/pages/dashboard/charts/CustomBarJobsCreated.tsx b/src/pages/dashboard/charts/CustomBarJobsCreated.tsx new file mode 100644 index 000000000..4df4fe024 --- /dev/null +++ b/src/pages/dashboard/charts/CustomBarJobsCreated.tsx @@ -0,0 +1,26 @@ +import React from "react"; + +interface CustomBarProps { + fill: string; + x: number; + y: number; + width: number; + height: number; +} + +export const CustomBar: React.FC = ({ fill, x, y, width, height }) => { + const radius = 5; + const path = ` + M${x},${y + height} + L${x},${y + radius} + Q${x},${y} ${x + radius},${y} + L${x + width - radius},${y} + Q${x + width},${y} ${x + width},${y + radius} + L${x + width},${y + height} + Z + `; + + return ; +}; + +export default CustomBar; diff --git a/src/pages/dashboard/charts/CustomLegendJobsCreated.tsx b/src/pages/dashboard/charts/CustomLegendJobsCreated.tsx new file mode 100644 index 000000000..74c38ecdb --- /dev/null +++ b/src/pages/dashboard/charts/CustomLegendJobsCreated.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { LegendProps } from "recharts"; + +import { getPercentage } from "@/utils/dashboardUtils"; + +interface CustomLegendProps extends LegendProps { + totals: { [key: string]: number }; + totalJobs: number; +} + +export const CustomLegend: React.FC = ({ payload, totals, totalJobs }) => { + if (!payload) return null; + + return ( +
    + {payload.map((entry: any, index: number) => ( +
  • + + + + + {entry.value} + + {`Total: ${totals[entry?.value]?.toLocaleString()} (${getPercentage(totals[entry.value], totalJobs)}%)`} + + +
  • + ))} +
+ ); +}; + +export default CustomLegend; diff --git a/src/pages/dashboard/charts/CustomTooltipJobsCreated.tsx b/src/pages/dashboard/charts/CustomTooltipJobsCreated.tsx new file mode 100644 index 000000000..c6b308fe0 --- /dev/null +++ b/src/pages/dashboard/charts/CustomTooltipJobsCreated.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +export const CustomTooltip: React.FC = ({ active, payload, label }) => { + if (!active || !payload || !payload.length) return null; + return ( +
+

{label}

+ {payload.map((item: any, index: number) => ( +

+ {item.name}: + {item.value.toLocaleString()} +

+ ))} +
+ ); +}; + +export default CustomTooltip; diff --git a/src/pages/dashboard/charts/CustomYAxisTickJobsCreated.tsx b/src/pages/dashboard/charts/CustomYAxisTickJobsCreated.tsx new file mode 100644 index 000000000..0975c56ed --- /dev/null +++ b/src/pages/dashboard/charts/CustomYAxisTickJobsCreated.tsx @@ -0,0 +1,17 @@ +import React from "react"; + +import { formatNumberLocaleString } from "@/utils/dashboardUtils"; + +export const CustomYAxisTick: React.FC = ({ x, y, payload }) => { + const formattedValue = formatNumberLocaleString(payload.value); + + return ( + + + {formattedValue} + + + ); +}; + +export default CustomYAxisTick; diff --git a/src/pages/dashboard/charts/GroupedBarChart.tsx b/src/pages/dashboard/charts/GroupedBarChart.tsx new file mode 100644 index 000000000..925908d19 --- /dev/null +++ b/src/pages/dashboard/charts/GroupedBarChart.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; + +import { calculateTotals, GroupedBarChartData } from "@/utils/dashboardUtils"; + +import { CustomBar } from "./CustomBarJobsCreated"; +import { CustomLegend } from "./CustomLegendJobsCreated"; +import { CustomTooltip } from "./CustomTooltipJobsCreated"; +import { CustomYAxisTick } from "./CustomYAxisTickJobsCreated"; + +const GroupedBarChart: React.FC<{ data: GroupedBarChartData }> = ({ data }) => { + const { type, chartData, total, maxValue } = data; + const totals = calculateTotals(data); + + return ( + + + + + } /> + } cursor={{ fill: "rgba(0, 0, 0, 0.05)" }} /> + } /> + } + /> + } + /> + + + ); +}; + +export default GroupedBarChart; diff --git a/src/pages/dashboard/components/SecDashboard.tsx b/src/pages/dashboard/components/SecDashboard.tsx index b3a4832ea..12d5a8744 100644 --- a/src/pages/dashboard/components/SecDashboard.tsx +++ b/src/pages/dashboard/components/SecDashboard.tsx @@ -14,6 +14,7 @@ import { CHART_TYPES } from "@/constants/dashbordConsts"; import { TextVariants } from "@/types/common"; import { getRestorationGoalDataForChart, getRestorationGoalResumeData } from "@/utils/dashboardUtils"; +import GroupedBarChart from "../charts/GroupedBarChart"; import HorizontalStackedBarChart from "../charts/HorizontalStackedBarChart"; import MultiLineChart from "../charts/MultiLineChart"; import { DashboardDataProps } from "../project/index.page"; @@ -142,9 +143,12 @@ const SecDashboard = ({ />
- + + + + {data?.graphic}
diff --git a/src/pages/dashboard/hooks/useDashboardData.ts b/src/pages/dashboard/hooks/useDashboardData.ts index dc6e1e8b2..648b15df1 100644 --- a/src/pages/dashboard/hooks/useDashboardData.ts +++ b/src/pages/dashboard/hooks/useDashboardData.ts @@ -34,8 +34,6 @@ export const useDashboardData = (filters: any) => { tooltip: "Number of jobs created to date." } ]); - const [totalFtJobs, setTotalFtJobs] = useState({ value: 0 }); - const [totalPtJobs, setTotalPtJobs] = useState({ value: 0 }); const projectUuid = filters.project?.project_uuid; const queryParamsCountryProject: any = (country?: string, project?: string) => { if (country) { @@ -98,15 +96,6 @@ export const useDashboardData = (filters: any) => { queryParams: queryParams }); - useEffect(() => { - if (jobsCreatedData?.data?.total_ft) { - setTotalFtJobs({ value: jobsCreatedData?.data?.total_ft }); - } - if (jobsCreatedData?.data?.total_pt) { - setTotalPtJobs({ value: jobsCreatedData?.data?.total_pt }); - } - }, [jobsCreatedData]); - useEffect(() => { if (topData?.data) { const projects = topData.data.top_projects_most_planted_trees.slice(0, 5); @@ -141,8 +130,7 @@ export const useDashboardData = (filters: any) => { return { dashboardHeader, dashboardRestorationGoalData, - totalFtJobs, - totalPtJobs, + jobsCreatedData, numberTreesPlanted, topProject, refetchTotalSectionHeader, diff --git a/src/pages/dashboard/index.page.tsx b/src/pages/dashboard/index.page.tsx index 496876dc2..52a9cff6b 100644 --- a/src/pages/dashboard/index.page.tsx +++ b/src/pages/dashboard/index.page.tsx @@ -7,15 +7,13 @@ import ToolTip from "@/components/elements/Tooltip/Tooltip"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import PageCard from "@/components/extensive/PageElements/Card/PageCard"; import PageRow from "@/components/extensive/PageElements/Row/PageRow"; -import { CHART_TYPES } from "@/constants/dashbordConsts"; +import { CHART_TYPES, JOBS_CREATED_CHART_TYPE } from "@/constants/dashbordConsts"; import { useDashboardContext } from "@/context/dashboard.provider"; import ContentOverview from "./components/ContentOverview"; import SecDashboard from "./components/SecDashboard"; import { useDashboardData } from "./hooks/useDashboardData"; import { - JOBS_CREATED_BY_AGE, - JOBS_CREATED_BY_GENDER, LABEL_LEGEND, TOTAL_VOLUNTEERS, VOLUNTEERS_CREATED_BY_AGE, @@ -40,8 +38,7 @@ const Dashboard = () => { const { dashboardHeader, dashboardRestorationGoalData, - totalFtJobs, - totalPtJobs, + jobsCreatedData, numberTreesPlanted, topProject, refetchTotalSectionHeader, @@ -173,6 +170,33 @@ const Dashboard = () => { ) : []; + const parseJobCreatedByType = (data: any, type: string) => { + if (!data) return { type, chartData: [] }; + + const ptWomen = data.total_pt_women || 0; + const ptMen = data.total_pt_men || 0; + const ptYouth = data.total_pt_youth || 0; + const ptNonYouth = data.total_pt_non_youth || 0; + const maxValue = Math.max(ptWomen, ptMen, ptYouth, ptNonYouth); + const chartData = [ + { + name: "Part-Time", + [type === JOBS_CREATED_CHART_TYPE.gender ? "Women" : "Youth"]: + data[`total_pt_${type === JOBS_CREATED_CHART_TYPE.gender ? "women" : "youth"}`], + [type === JOBS_CREATED_CHART_TYPE.gender ? "Men" : "Non-Youth"]: + data[`total_pt_${type === JOBS_CREATED_CHART_TYPE.gender ? "men" : "non_youth"}`] + }, + { + name: "Full-Time", + [type === JOBS_CREATED_CHART_TYPE.gender ? "Women" : "Youth"]: + data[`total_ft_${type === JOBS_CREATED_CHART_TYPE.gender ? "women" : "youth"}`], + [type === JOBS_CREATED_CHART_TYPE.gender ? "Men" : "Non-Youth"]: + data[`total_ft_${type === JOBS_CREATED_CHART_TYPE.gender ? "men" : "non_youth"}`] + } + ]; + return { type, chartData, total: data.totalJobsCreated, maxValue }; + }; + return (
@@ -281,7 +305,7 @@ const Dashboard = () => {
{ /> {
Date: Tue, 22 Oct 2024 12:06:22 -0700 Subject: [PATCH 035/102] [TM-1269] Regenerate API --- src/generated/apiComponents.ts | 150 +++++++++++++++++ src/generated/apiSchemas.ts | 152 ++++++++++++++++++ .../v3/userService/userServiceComponents.ts | 84 ++++++++++ .../v3/userService/userServicePredicates.ts | 6 + 4 files changed, 392 insertions(+) diff --git a/src/generated/apiComponents.ts b/src/generated/apiComponents.ts index b927b6649..d6d8b50d7 100644 --- a/src/generated/apiComponents.ts +++ b/src/generated/apiComponents.ts @@ -34403,6 +34403,151 @@ export const useGetV2DashboardTopTreesPlanted = ; + +export type GetV2DashboardIndicatorHectaresRestorationResponse = { + data?: { + restoration_strategies_represented?: { + /** + * Total amount for tree planting projects. + */ + ["tree-planting"]?: number; + /** + * Total amount for projects involving both tree planting and direct seeding. + */ + ["tree-planting,direct-seeding"]?: number; + /** + * Total amount for assisted natural regeneration projects. + */ + ["assisted-natural-regeneration"]?: number; + /** + * Total amount for projects involving both tree planting and assisted natural regeneration. + */ + ["tree-planting,assisted-natural-regeneration"]?: number; + /** + * Total amount for direct seeding projects. + */ + ["direct-seeding"]?: number; + /** + * Total amount for control projects. + */ + control?: number; + /** + * Total amount for projects with no specific restoration category. + */ + ["null"]?: number; + }; + target_land_use_types_represented?: { + /** + * Total amount for projects without a defined land use type. + */ + ["null"]?: number; + /** + * Total amount for projects involving natural forest. + */ + ["natural-forest"]?: number; + /** + * Total amount for agroforest projects. + */ + agroforest?: number; + /** + * Total amount for silvopasture projects. + */ + silvopasture?: number; + /** + * Total amount for woodlot or plantation projects. + */ + ["woodlot-or-plantation"]?: number; + /** + * Total amount for riparian area or wetland projects. + */ + ["riparian-area-or-wetland"]?: number; + /** + * Total amount for projects involving both agroforest and riparian area or wetland. + */ + ["agroforest,riparian-area-or-wetland"]?: number; + /** + * Total amount for projects involving both riparian area or wetland and woodlot or plantation. + */ + ["riparian-area-or-wetland,woodlot-or-plantation"]?: number; + /** + * Total amount for projects involving open natural ecosystem or grasslands. + */ + ["Open natural ecosystem or Grasslands"]?: number; + /** + * Total amount for urban forest projects. + */ + ["urban-forest"]?: number; + }; + }; +}; + +export type GetV2DashboardIndicatorHectaresRestorationVariables = { + queryParams?: GetV2DashboardIndicatorHectaresRestorationQueryParams; +} & ApiContext["fetcherOptions"]; + +/** + * This endpoint returns hectares restored using data from indicators 5 (restoration strategies) and 6 (target land use types). + */ +export const fetchGetV2DashboardIndicatorHectaresRestoration = ( + variables: GetV2DashboardIndicatorHectaresRestorationVariables, + signal?: AbortSignal +) => + apiFetch< + GetV2DashboardIndicatorHectaresRestorationResponse, + GetV2DashboardIndicatorHectaresRestorationError, + undefined, + {}, + GetV2DashboardIndicatorHectaresRestorationQueryParams, + {} + >({ url: "/v2/dashboard/indicator/hectares-restoration", method: "get", ...variables, signal }); + +/** + * This endpoint returns hectares restored using data from indicators 5 (restoration strategies) and 6 (target land use types). + */ +export const useGetV2DashboardIndicatorHectaresRestoration = < + TData = GetV2DashboardIndicatorHectaresRestorationResponse +>( + variables: GetV2DashboardIndicatorHectaresRestorationVariables, + options?: Omit< + reactQuery.UseQueryOptions< + GetV2DashboardIndicatorHectaresRestorationResponse, + GetV2DashboardIndicatorHectaresRestorationError, + TData + >, + "queryKey" | "queryFn" + > +) => { + const { fetcherOptions, queryOptions, queryKeyFn } = useApiContext(options); + return reactQuery.useQuery< + GetV2DashboardIndicatorHectaresRestorationResponse, + GetV2DashboardIndicatorHectaresRestorationError, + TData + >( + queryKeyFn({ + path: "/v2/dashboard/indicator/hectares-restoration", + operationId: "getV2DashboardIndicatorHectaresRestoration", + variables + }), + ({ signal }) => fetchGetV2DashboardIndicatorHectaresRestoration({ ...fetcherOptions, ...variables }, signal), + { + ...options, + ...queryOptions + } + ); +}; + export type GetV2ProjectPipelineQueryParams = { /** * Optional. Filter counts and metrics by country. @@ -36534,6 +36679,11 @@ export type QueryOperation = operationId: "getV2DashboardTopTreesPlanted"; variables: GetV2DashboardTopTreesPlantedVariables; } + | { + path: "/v2/dashboard/indicator/hectares-restoration"; + operationId: "getV2DashboardIndicatorHectaresRestoration"; + variables: GetV2DashboardIndicatorHectaresRestorationVariables; + } | { path: "/v2/project-pipeline"; operationId: "getV2ProjectPipeline"; diff --git a/src/generated/apiSchemas.ts b/src/generated/apiSchemas.ts index 2fe52628f..7a38edc4f 100644 --- a/src/generated/apiSchemas.ts +++ b/src/generated/apiSchemas.ts @@ -23417,3 +23417,155 @@ export type FileResource = { is_public?: boolean; is_cover?: boolean; }; + +export type DashboardIndicatorHectaresRestorationResponse = { + data?: { + restoration_strategies_represented?: { + /** + * Total amount for tree planting projects. + */ + ["tree-planting"]?: number; + /** + * Total amount for projects involving both tree planting and direct seeding. + */ + ["tree-planting,direct-seeding"]?: number; + /** + * Total amount for assisted natural regeneration projects. + */ + ["assisted-natural-regeneration"]?: number; + /** + * Total amount for projects involving both tree planting and assisted natural regeneration. + */ + ["tree-planting,assisted-natural-regeneration"]?: number; + /** + * Total amount for direct seeding projects. + */ + ["direct-seeding"]?: number; + /** + * Total amount for control projects. + */ + control?: number; + /** + * Total amount for projects with no specific restoration category. + */ + ["null"]?: number; + }; + target_land_use_types_represented?: { + /** + * Total amount for projects without a defined land use type. + */ + ["null"]?: number; + /** + * Total amount for projects involving natural forest. + */ + ["natural-forest"]?: number; + /** + * Total amount for agroforest projects. + */ + agroforest?: number; + /** + * Total amount for silvopasture projects. + */ + silvopasture?: number; + /** + * Total amount for woodlot or plantation projects. + */ + ["woodlot-or-plantation"]?: number; + /** + * Total amount for riparian area or wetland projects. + */ + ["riparian-area-or-wetland"]?: number; + /** + * Total amount for projects involving both agroforest and riparian area or wetland. + */ + ["agroforest,riparian-area-or-wetland"]?: number; + /** + * Total amount for projects involving both riparian area or wetland and woodlot or plantation. + */ + ["riparian-area-or-wetland,woodlot-or-plantation"]?: number; + /** + * Total amount for projects involving open natural ecosystem or grasslands. + */ + ["Open natural ecosystem or Grasslands"]?: number; + /** + * Total amount for urban forest projects. + */ + ["urban-forest"]?: number; + }; + }; +}; + +export type DashboardIndicatorHectaresRestorationData = { + restoration_strategies_represented?: { + /** + * Total amount for tree planting projects. + */ + ["tree-planting"]?: number; + /** + * Total amount for projects involving both tree planting and direct seeding. + */ + ["tree-planting,direct-seeding"]?: number; + /** + * Total amount for assisted natural regeneration projects. + */ + ["assisted-natural-regeneration"]?: number; + /** + * Total amount for projects involving both tree planting and assisted natural regeneration. + */ + ["tree-planting,assisted-natural-regeneration"]?: number; + /** + * Total amount for direct seeding projects. + */ + ["direct-seeding"]?: number; + /** + * Total amount for control projects. + */ + control?: number; + /** + * Total amount for projects with no specific restoration category. + */ + ["null"]?: number; + }; + target_land_use_types_represented?: { + /** + * Total amount for projects without a defined land use type. + */ + ["null"]?: number; + /** + * Total amount for projects involving natural forest. + */ + ["natural-forest"]?: number; + /** + * Total amount for agroforest projects. + */ + agroforest?: number; + /** + * Total amount for silvopasture projects. + */ + silvopasture?: number; + /** + * Total amount for woodlot or plantation projects. + */ + ["woodlot-or-plantation"]?: number; + /** + * Total amount for riparian area or wetland projects. + */ + ["riparian-area-or-wetland"]?: number; + /** + * Total amount for projects involving both agroforest and riparian area or wetland. + */ + ["agroforest,riparian-area-or-wetland"]?: number; + /** + * Total amount for projects involving both riparian area or wetland and woodlot or plantation. + */ + ["riparian-area-or-wetland,woodlot-or-plantation"]?: number; + /** + * Total amount for projects involving open natural ecosystem or grasslands. + */ + ["Open natural ecosystem or Grasslands"]?: number; + /** + * Total amount for urban forest projects. + */ + ["urban-forest"]?: number; + }; +}; diff --git a/src/generated/v3/userService/userServiceComponents.ts b/src/generated/v3/userService/userServiceComponents.ts index 6a60c063d..a25e014a0 100644 --- a/src/generated/v3/userService/userServiceComponents.ts +++ b/src/generated/v3/userService/userServiceComponents.ts @@ -152,3 +152,87 @@ export const usersFind = (variables: UsersFindVariables, signal?: AbortSignal) = ...variables, signal }); + +export type HealthControllerCheckError = Fetcher.ErrorWrapper<{ + status: 503; + payload: { + /** + * @example error + */ + status?: string; + /** + * @example {"database":{"status":"up"}} + */ + info?: { + [key: string]: { + status: string; + } & { + [key: string]: any; + }; + } | null; + /** + * @example {"redis":{"status":"down","message":"Could not connect"}} + */ + error?: { + [key: string]: { + status: string; + } & { + [key: string]: any; + }; + } | null; + /** + * @example {"database":{"status":"up"},"redis":{"status":"down","message":"Could not connect"}} + */ + details?: { + [key: string]: { + status: string; + } & { + [key: string]: any; + }; + }; + }; +}>; + +export type HealthControllerCheckResponse = { + /** + * @example ok + */ + status?: string; + /** + * @example {"database":{"status":"up"}} + */ + info?: { + [key: string]: { + status: string; + } & { + [key: string]: any; + }; + } | null; + /** + * @example {} + */ + error?: { + [key: string]: { + status: string; + } & { + [key: string]: any; + }; + } | null; + /** + * @example {"database":{"status":"up"}} + */ + details?: { + [key: string]: { + status: string; + } & { + [key: string]: any; + }; + }; +}; + +export const healthControllerCheck = (signal?: AbortSignal) => + userServiceFetch({ + url: "/health", + method: "get", + signal + }); diff --git a/src/generated/v3/userService/userServicePredicates.ts b/src/generated/v3/userService/userServicePredicates.ts index 16771c4b5..8c16e14db 100644 --- a/src/generated/v3/userService/userServicePredicates.ts +++ b/src/generated/v3/userService/userServicePredicates.ts @@ -13,3 +13,9 @@ export const usersFindIsFetching = (variables: UsersFindVariables) => (store: Ap export const usersFindFetchFailed = (variables: UsersFindVariables) => (store: ApiDataStore) => fetchFailed<{}, UsersFindPathParams>({ store, url: "/users/v3/users/{id}", method: "get", ...variables }); + +export const healthControllerCheckIsFetching = (store: ApiDataStore) => + isFetching<{}, {}>({ store, url: "/health", method: "get" }); + +export const healthControllerCheckFetchFailed = (store: ApiDataStore) => + fetchFailed<{}, {}>({ store, url: "/health", method: "get" }); From 8506573197603ca2ff536fc2c2c8492882221b45 Mon Sep 17 00:00:00 2001 From: cesarLima1 <105736261+cesarLima1@users.noreply.github.com> Date: Tue, 22 Oct 2024 15:22:16 -0400 Subject: [PATCH 036/102] [TM-1371] Doughnut chart volunteers (#572) * [TM-1369] implement jobs created data and util functions * [TM-1370] add grouped bar chart for jobs created * [TM-1371] doughnut chart for volunteerss section * [TM-1370] add missing default export for components * [TM-1371] add missing default export --- src/constants/dashbordConsts.ts | 3 +- .../charts/CustomLegendVolunteers.tsx | 38 +++++++++++++++++++ src/pages/dashboard/charts/CustomTooltip.tsx | 18 +++++++++ src/pages/dashboard/charts/DoughnutChart.tsx | 30 +++++++++++++++ .../dashboard/charts/GroupedBarChart.tsx | 2 +- .../dashboard/components/SecDashboard.tsx | 4 ++ src/pages/dashboard/hooks/useDashboardData.ts | 8 +++- src/pages/dashboard/index.page.tsx | 30 ++++++++++----- src/utils/dashboardUtils.ts | 35 +++++++++++++++++ 9 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 src/pages/dashboard/charts/CustomLegendVolunteers.tsx create mode 100644 src/pages/dashboard/charts/CustomTooltip.tsx create mode 100644 src/pages/dashboard/charts/DoughnutChart.tsx diff --git a/src/constants/dashbordConsts.ts b/src/constants/dashbordConsts.ts index 46dab989c..eaaf5d901 100644 --- a/src/constants/dashbordConsts.ts +++ b/src/constants/dashbordConsts.ts @@ -1,7 +1,8 @@ export const CHART_TYPES = { multiLineChart: "multiLineChart", treesPlantedBarChart: "treesPlantedBarChart", - groupedBarChart: "groupedBarChart" + groupedBarChart: "groupedBarChart", + doughnutChart: "doughnutChart" }; export const JOBS_CREATED_CHART_TYPE = { diff --git a/src/pages/dashboard/charts/CustomLegendVolunteers.tsx b/src/pages/dashboard/charts/CustomLegendVolunteers.tsx new file mode 100644 index 000000000..77a0fb55a --- /dev/null +++ b/src/pages/dashboard/charts/CustomLegendVolunteers.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +import { getPercentageVolunteers } from "@/utils/dashboardUtils"; + +interface CustomLegendProps { + payload?: Array<{ value: string; color: string }>; + totals: { [key: string]: number }; + totalVolunteers: number; +} + +export const CustomLegendVolunteers: React.FC = ({ payload, totals, totalVolunteers }) => { + if (!payload) return null; + + return ( +
+
    + {payload.map((entry, index) => ( +
  • + + + + + {entry.value} + + {`Total: ${totals[entry.value].toLocaleString()} (${getPercentageVolunteers( + totals[entry.value], + totalVolunteers + )}%)`} + + +
  • + ))} +
+
+ ); +}; + +export default CustomLegendVolunteers; diff --git a/src/pages/dashboard/charts/CustomTooltip.tsx b/src/pages/dashboard/charts/CustomTooltip.tsx new file mode 100644 index 000000000..c6b308fe0 --- /dev/null +++ b/src/pages/dashboard/charts/CustomTooltip.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +export const CustomTooltip: React.FC = ({ active, payload, label }) => { + if (!active || !payload || !payload.length) return null; + return ( +
+

{label}

+ {payload.map((item: any, index: number) => ( +

+ {item.name}: + {item.value.toLocaleString()} +

+ ))} +
+ ); +}; + +export default CustomTooltip; diff --git a/src/pages/dashboard/charts/DoughnutChart.tsx b/src/pages/dashboard/charts/DoughnutChart.tsx new file mode 100644 index 000000000..e1bcac4d6 --- /dev/null +++ b/src/pages/dashboard/charts/DoughnutChart.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; + +import { calculateTotalsVolunteers, ChartDataVolunteers, COLORS_VOLUNTEERS } from "@/utils/dashboardUtils"; + +import { CustomLegendVolunteers } from "./CustomLegendVolunteers"; +import { CustomTooltip } from "./CustomTooltip"; + +const DoughnutChart: React.FC<{ data: ChartDataVolunteers }> = ({ data }) => { + const { chartData, total } = data; + const totals = calculateTotalsVolunteers(chartData); + + return ( +
+ + + + {chartData.map((entry, index) => ( + + ))} + + } /> + } /> + + +
+ ); +}; + +export default DoughnutChart; diff --git a/src/pages/dashboard/charts/GroupedBarChart.tsx b/src/pages/dashboard/charts/GroupedBarChart.tsx index 925908d19..2ec355d36 100644 --- a/src/pages/dashboard/charts/GroupedBarChart.tsx +++ b/src/pages/dashboard/charts/GroupedBarChart.tsx @@ -5,7 +5,7 @@ import { calculateTotals, GroupedBarChartData } from "@/utils/dashboardUtils"; import { CustomBar } from "./CustomBarJobsCreated"; import { CustomLegend } from "./CustomLegendJobsCreated"; -import { CustomTooltip } from "./CustomTooltipJobsCreated"; +import { CustomTooltip } from "./CustomTooltip"; import { CustomYAxisTick } from "./CustomYAxisTickJobsCreated"; const GroupedBarChart: React.FC<{ data: GroupedBarChartData }> = ({ data }) => { diff --git a/src/pages/dashboard/components/SecDashboard.tsx b/src/pages/dashboard/components/SecDashboard.tsx index 12d5a8744..128e7c2a1 100644 --- a/src/pages/dashboard/components/SecDashboard.tsx +++ b/src/pages/dashboard/components/SecDashboard.tsx @@ -14,6 +14,7 @@ import { CHART_TYPES } from "@/constants/dashbordConsts"; import { TextVariants } from "@/types/common"; import { getRestorationGoalDataForChart, getRestorationGoalResumeData } from "@/utils/dashboardUtils"; +import DoughnutChart from "../charts/DoughnutChart"; import GroupedBarChart from "../charts/GroupedBarChart"; import HorizontalStackedBarChart from "../charts/HorizontalStackedBarChart"; import MultiLineChart from "../charts/MultiLineChart"; @@ -149,6 +150,9 @@ const SecDashboard = ({ + + + {data?.graphic}
diff --git a/src/pages/dashboard/hooks/useDashboardData.ts b/src/pages/dashboard/hooks/useDashboardData.ts index 648b15df1..bc1a86dfc 100644 --- a/src/pages/dashboard/hooks/useDashboardData.ts +++ b/src/pages/dashboard/hooks/useDashboardData.ts @@ -10,7 +10,8 @@ import { useGetV2DashboardTopTreesPlanted, useGetV2DashboardTotalSectionHeader, useGetV2DashboardTreeRestorationGoal, - useGetV2DashboardViewProjectList + useGetV2DashboardViewProjectList, + useGetV2DashboardVolunteersSurvivalRate } from "@/generated/apiComponents"; import { DashboardTreeRestorationGoalResponse } from "@/generated/apiSchemas"; import { createQueryParams } from "@/utils/dashboardUtils"; @@ -96,6 +97,10 @@ export const useDashboardData = (filters: any) => { queryParams: queryParams }); + const { data: dashboardVolunteersSurvivalRate } = useGetV2DashboardVolunteersSurvivalRate({ + queryParams: queryParams + }); + useEffect(() => { if (topData?.data) { const projects = topData.data.top_projects_most_planted_trees.slice(0, 5); @@ -131,6 +136,7 @@ export const useDashboardData = (filters: any) => { dashboardHeader, dashboardRestorationGoalData, jobsCreatedData, + dashboardVolunteersSurvivalRate, numberTreesPlanted, topProject, refetchTotalSectionHeader, diff --git a/src/pages/dashboard/index.page.tsx b/src/pages/dashboard/index.page.tsx index 52a9cff6b..182789acb 100644 --- a/src/pages/dashboard/index.page.tsx +++ b/src/pages/dashboard/index.page.tsx @@ -9,16 +9,12 @@ import PageCard from "@/components/extensive/PageElements/Card/PageCard"; import PageRow from "@/components/extensive/PageElements/Row/PageRow"; import { CHART_TYPES, JOBS_CREATED_CHART_TYPE } from "@/constants/dashbordConsts"; import { useDashboardContext } from "@/context/dashboard.provider"; +import { formatLabelsVolunteers } from "@/utils/dashboardUtils"; import ContentOverview from "./components/ContentOverview"; import SecDashboard from "./components/SecDashboard"; import { useDashboardData } from "./hooks/useDashboardData"; -import { - LABEL_LEGEND, - TOTAL_VOLUNTEERS, - VOLUNTEERS_CREATED_BY_AGE, - VOLUNTEERS_CREATED_BY_GENDER -} from "./mockedData/dashboard"; +import { LABEL_LEGEND } from "./mockedData/dashboard"; export interface DashboardTableDataProps { label: string; @@ -39,6 +35,7 @@ const Dashboard = () => { dashboardHeader, dashboardRestorationGoalData, jobsCreatedData, + dashboardVolunteersSurvivalRate, numberTreesPlanted, topProject, refetchTotalSectionHeader, @@ -197,6 +194,17 @@ const Dashboard = () => { return { type, chartData, total: data.totalJobsCreated, maxValue }; }; + const parseVolunteersByType = (data: any, type: string) => { + if (!data) return { type, chartData: [] }; + const firstvalue = type === JOBS_CREATED_CHART_TYPE.gender ? "women" : "youth"; + const secondValue = type === JOBS_CREATED_CHART_TYPE.gender ? "men" : "non_youth"; + const chartData = [ + { name: formatLabelsVolunteers(firstvalue), value: data[`${firstvalue}_volunteers`] }, + { name: formatLabelsVolunteers(secondValue), value: data[`${secondValue}_volunteers`] } + ]; + return { type, chartData, total: data.total_volunteers }; + }; + return (
@@ -345,7 +353,7 @@ const Dashboard = () => {
{
value ? (value >= 1000000 ? `${(value / 1000000).toFixed(2)}M` : value.toLocaleString("en-US")) : ""; @@ -150,3 +161,27 @@ export const calculateTotals = (data: GroupedBarChartData): { [key: string]: num return acc; }, {} as { [key: string]: number }); }; + +export const formatLabelsVolunteers = (value: string): string => { + const formattedValues: { [key: string]: string } = { + women: "Women", + youth: "Youth", + men: "Men", + non_youth: "Non-Youth" + }; + + return formattedValues[value] || value; +}; + +export const COLORS_VOLUNTEERS = ["#7BBD31", "#27A9E0"]; + +export const getPercentageVolunteers = (value: number, total: number): string => { + return ((value / total) * 100).toFixed(1); +}; + +export const calculateTotalsVolunteers = (chartData: ChartDataItem[]): { [key: string]: number } => { + return chartData.reduce<{ [key: string]: number }>((acc, item) => { + acc[item.name] = item.value as number; + return acc; + }, {}); +}; From 71fb4aa69a4a7c77cdd6ec04db662b01e085efa8 Mon Sep 17 00:00:00 2001 From: Jorge Monroy Date: Tue, 22 Oct 2024 15:59:25 -0400 Subject: [PATCH 037/102] [TM-1343] add polygons visible in dashboard with popups (#578) * [TM-1343] add polygons visible in dashboard with popups * [TM-1343] remove useless lo * [TM-1343[ remove logs * [TM-1343] missing variable --- .../ResourceTabs/PolygonReviewTab/index.tsx | 6 +-- src/components/elements/Map-mapbox/Map.tsx | 36 +++------------ .../Map-mapbox/components/DashboardPopup.tsx | 29 ++++++++++-- src/components/elements/Map-mapbox/utils.ts | 46 ++++++++++++------- .../EntityMapAndGalleryCard.tsx | 4 +- .../extensive/WizardForm/FormSummaryRow.tsx | 4 +- .../dashboard/components/ContentOverview.tsx | 5 +- src/pages/dashboard/hooks/useDashboardData.ts | 8 +++- src/pages/dashboard/index.page.tsx | 2 + 9 files changed, 80 insertions(+), 60 deletions(-) diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx index 86d5d276e..eaeaa9514 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx @@ -13,7 +13,7 @@ import { MapContainer } from "@/components/elements/Map-mapbox/Map"; import { addSourcesToLayers, downloadSiteGeoJsonPolygons, - mapPolygonData, + parsePolygonData, storePolygon } from "@/components/elements/Map-mapbox/utils"; import Menu from "@/components/elements/Menu/Menu"; @@ -201,7 +201,7 @@ const PolygonReviewTab: FC = props => { uuid: data.poly_id })); - const polygonDataMap = mapPolygonData(sitePolygonData); + const polygonDataMap = parsePolygonData(sitePolygonData); const { openModal, closeModal } = useModalContext(); @@ -230,7 +230,7 @@ const PolygonReviewTab: FC = props => { refetch?.(); const { map } = mapFunctions; if (map?.current) { - addSourcesToLayers(map.current, polygonDataMap); + addSourcesToLayers(map.current, polygonDataMap, undefined); } closeModal(ModalId.DELETE_POLYGON); } diff --git a/src/components/elements/Map-mapbox/Map.tsx b/src/components/elements/Map-mapbox/Map.tsx index 2af322bc0..9fba75b48 100644 --- a/src/components/elements/Map-mapbox/Map.tsx +++ b/src/components/elements/Map-mapbox/Map.tsx @@ -58,13 +58,11 @@ import { ZoomControl } from "./MapControls/ZoomControl"; import { addDeleteLayer, addFilterOnLayer, - addGeojsonSourceToLayer, addGeojsonToDraw, addMarkerAndZoom, addMediaSourceAndLayer, addPopupsToMap, addSourcesToLayers, - addSourceToLayer, drawTemporaryPolygon, removeMediaLayer, removePopups, @@ -199,35 +197,13 @@ export const MapContainer = ({ } }; }, []); - useEffect(() => { if (!map) return; if (location && location.lat !== 0 && location.lng !== 0) { addMarkerAndZoom(map.current, location); } }, [map, location]); - useEffect(() => { - if (map?.current && isDashboard && map.current.isStyleLoaded()) { - const layerCountry = layersList.find(layer => layer.name === LAYERS_NAMES.WORLD_COUNTRIES); - if (layerCountry) { - addSourceToLayer(layerCountry, map.current, undefined); - } - const centroidsLayer = layersList.find(layer => layer.name === LAYERS_NAMES.CENTROIDS); - if (centroidsLayer && centroids) { - addGeojsonSourceToLayer(centroids, map.current, centroidsLayer); - } - addPopupsToMap( - map.current, - DashboardPopup, - setPolygonFromMap, - sitePolygonData, - tooltipType, - editPolygonSelected, - setEditPolygon, - draw.current - ); - } - }, [map, isDashboard, map?.current?.isStyleLoaded()]); + useEffect(() => { if (map?.current && draw?.current) { if (isUserDrawingEnabled) { @@ -245,19 +221,19 @@ export const MapContainer = ({ if (map?.current && !_.isEmpty(polygonsData)) { const currentMap = map.current as mapboxgl.Map; const setupMap = () => { - addSourcesToLayers(currentMap, polygonsData); + addSourcesToLayers(currentMap, polygonsData, centroids); setChangeStyle(true); - if (showPopups) { addPopupsToMap( currentMap, - AdminPopup, + isDashboard ? DashboardPopup : AdminPopup, setPolygonFromMap, sitePolygonData, tooltipType, editPolygonSelected, setEditPolygon, - draw.current + draw.current, + isDashboard ); } }; @@ -483,7 +459,7 @@ export const MapContainer = ({ await reloadSiteData?.(); } onCancel(polygonsData); - addSourcesToLayers(map.current, polygonsData); + addSourcesToLayers(map.current, polygonsData, centroids); setShouldRefetchPolygonData(true); openNotification( "success", diff --git a/src/components/elements/Map-mapbox/components/DashboardPopup.tsx b/src/components/elements/Map-mapbox/components/DashboardPopup.tsx index a43c6148e..138cafd9d 100644 --- a/src/components/elements/Map-mapbox/components/DashboardPopup.tsx +++ b/src/components/elements/Map-mapbox/components/DashboardPopup.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { LAYERS_NAMES } from "@/constants/layers"; import { + fetchGetV2DashboardPolygonDataUuid, fetchGetV2DashboardProjectDataUuid, fetchGetV2DashboardTotalSectionHeaderCountry } from "@/generated/apiComponents"; @@ -19,7 +20,8 @@ type Item = { export const DashboardPopup = (event: any) => { const isoCountry = event?.feature?.properties?.iso; - const projectUuid = event?.feature?.properties?.uuid; + const itemUuid = event?.feature?.properties?.uuid; + const { addPopupToMap, layerName } = event; const [items, setItems] = useState([]); @@ -66,7 +68,7 @@ export const DashboardPopup = (event: any) => { } async function fetchProjectData() { - const response: any = await fetchGetV2DashboardProjectDataUuid({ pathParams: { uuid: projectUuid } }); + const response: any = await fetchGetV2DashboardProjectDataUuid({ pathParams: { uuid: itemUuid } }); if (response) { const filteredItems = response.data .filter((item: any) => item.key !== "project_name") @@ -82,13 +84,32 @@ export const DashboardPopup = (event: any) => { addPopupToMap(); } } + async function fetchPolygonData() { + const response: any = await fetchGetV2DashboardPolygonDataUuid({ pathParams: { uuid: itemUuid } }); + if (response) { + const filteredItems = response.data + .filter((item: any) => item.key !== "poly_name") + .map((item: any) => ({ + id: item.key, + title: item.title, + value: item.value + })); + + const projectLabel = response.data.find((item: any) => item.key === "poly_name")?.value; + setLabel(projectLabel); + setItems(filteredItems); + addPopupToMap(); + } + } if (isoCountry && layerName === LAYERS_NAMES.WORLD_COUNTRIES) { fetchCountryData(); - } else if (projectUuid && layerName === LAYERS_NAMES.CENTROIDS) { + } else if (itemUuid && layerName === LAYERS_NAMES.CENTROIDS) { fetchProjectData(); + } else if (itemUuid && layerName === LAYERS_NAMES.POLYGON_GEOMETRY) { + fetchPolygonData(); } - }, [isoCountry, layerName, projectUuid]); + }, [isoCountry, layerName, itemUuid]); return ( diff --git a/src/components/elements/Map-mapbox/utils.ts b/src/components/elements/Map-mapbox/utils.ts index 6f32f95fd..7cb40b89e 100644 --- a/src/components/elements/Map-mapbox/utils.ts +++ b/src/components/elements/Map-mapbox/utils.ts @@ -145,10 +145,10 @@ const handleLayerClick = ( type: TooltipType, editPolygon: { isOpen: boolean; uuid: string; primary_uuid?: string }, setEditPolygon: (value: { isOpen: boolean; uuid: string; primary_uuid?: string }) => void, - layerName?: string + layerName?: string, + isDashboard?: string | undefined ) => { removePopups("POLYGON"); - const { lngLat, features } = e; const feature = features?.[0]; @@ -166,7 +166,6 @@ const handleLayerClick = ( const newPopup = createPopup(lngLat); - const isFetchdataLayer = layerName === LAYERS_NAMES.WORLD_COUNTRIES || layerName === LAYERS_NAMES.CENTROIDS; const commonProps: PopupComponentProps = { feature, popup: newPopup, @@ -176,8 +175,7 @@ const handleLayerClick = ( editPolygon, setEditPolygon }; - - if (isFetchdataLayer) { + if (isDashboard) { const addPopupToMap = () => { newPopup.addTo(map); }; @@ -357,12 +355,24 @@ export const addMediaSourceAndLayer = ( }); }; -export const addSourcesToLayers = (map: mapboxgl.Map, polygonsData: Record | undefined) => { - layersList.forEach((layer: LayerType) => { - if (map && layer.name === LAYERS_NAMES.POLYGON_GEOMETRY) { - addSourceToLayer(layer, map, polygonsData); - } - }); +export const addSourcesToLayers = ( + map: mapboxgl.Map, + polygonsData: Record | undefined, + centroids: DashboardGetProjectsData[] | undefined +) => { + if (map) { + layersList.forEach((layer: LayerType) => { + if (layer.name === LAYERS_NAMES.POLYGON_GEOMETRY) { + addSourceToLayer(layer, map, polygonsData); + } + if (layer.name === LAYERS_NAMES.WORLD_COUNTRIES) { + addSourceToLayer(layer, map, undefined); + } + if (layer.name === LAYERS_NAMES.CENTROIDS) { + addGeojsonSourceToLayer(centroids, map, layer); + } + }); + } }; export const addPopupsToMap = ( @@ -373,7 +383,8 @@ export const addPopupsToMap = ( type: TooltipType, editPolygon: { isOpen: boolean; uuid: string; primary_uuid?: string }, setEditPolygon: (value: { isOpen: boolean; uuid: string; primary_uuid?: string }) => void, - draw: MapboxDraw + draw: MapboxDraw, + isDashboard?: string | undefined ) => { if (popupComponent) { layersList.forEach((layer: LayerType) => { @@ -386,7 +397,8 @@ export const addPopupsToMap = ( type, editPolygon, setEditPolygon, - draw + draw, + isDashboard ); }); } @@ -401,7 +413,8 @@ export const addPopupToLayer = ( type: TooltipType, editPolygon: { isOpen: boolean; uuid: string; primary_uuid?: string }, setEditPolygon: (value: { isOpen: boolean; uuid: string; primary_uuid?: string }) => void, - draw: MapboxDraw + draw: MapboxDraw, + isDashboard?: string | undefined ) => { if (popupComponent) { const { name } = layer; @@ -432,7 +445,8 @@ export const addPopupToLayer = ( type, editPolygon, setEditPolygon, - name + name, + isDashboard ); }); }); @@ -659,7 +673,7 @@ export const formatCommentaryDate = (date: Date | null | undefined): string => { : "Unknown"; }; -export function mapPolygonData(sitePolygonData: SitePolygonsDataResponse | undefined) { +export function parsePolygonData(sitePolygonData: SitePolygonsDataResponse | undefined) { return (sitePolygonData ?? []).reduce((acc: Record, data: SitePolygon) => { if (data.status && data.poly_id !== undefined) { if (!acc[data.status]) { diff --git a/src/components/extensive/EntityMapAndGalleryCard/EntityMapAndGalleryCard.tsx b/src/components/extensive/EntityMapAndGalleryCard/EntityMapAndGalleryCard.tsx index e4d0aff26..d8bd440b5 100644 --- a/src/components/extensive/EntityMapAndGalleryCard/EntityMapAndGalleryCard.tsx +++ b/src/components/extensive/EntityMapAndGalleryCard/EntityMapAndGalleryCard.tsx @@ -10,7 +10,7 @@ import { VARIANT_FILE_INPUT_MODAL_ADD_IMAGES } from "@/components/elements/Input import { BBox } from "@/components/elements/Map-mapbox/GeoJSON"; import { useMap } from "@/components/elements/Map-mapbox/hooks/useMap"; import { MapContainer } from "@/components/elements/Map-mapbox/Map"; -import { mapPolygonData } from "@/components/elements/Map-mapbox/utils"; +import { parsePolygonData } from "@/components/elements/Map-mapbox/utils"; import { IconNames } from "@/components/extensive/Icon/Icon"; import PageCard from "@/components/extensive/PageElements/Card/PageCard"; import { getEntitiesOptions } from "@/constants/options/entities"; @@ -90,7 +90,7 @@ const EntityMapAndGalleryCard = ({ const mapBbox = sitePolygonData?.bbox as BBox; - const polygonDataMap = mapPolygonData(sitePolygonData?.polygonsData); + const polygonDataMap = parsePolygonData(sitePolygonData?.polygonsData); const { data, refetch, isLoading } = useGetV2MODELUUIDFiles({ // Currently only projects, sites, nurseries, projectReports, nurseryReports and siteReports are set up diff --git a/src/components/extensive/WizardForm/FormSummaryRow.tsx b/src/components/extensive/WizardForm/FormSummaryRow.tsx index 0de42b43c..321719dc2 100644 --- a/src/components/extensive/WizardForm/FormSummaryRow.tsx +++ b/src/components/extensive/WizardForm/FormSummaryRow.tsx @@ -16,7 +16,7 @@ import { getStrataTableColumns } from "@/components/elements/Inputs/DataTable/RH import { TreeSpeciesValue } from "@/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput"; import { useMap } from "@/components/elements/Map-mapbox/hooks/useMap"; import { MapContainer } from "@/components/elements/Map-mapbox/Map"; -import { getPolygonBbox, getSiteBbox, mapPolygonData } from "@/components/elements/Map-mapbox/utils"; +import { getPolygonBbox, getSiteBbox, parsePolygonData } from "@/components/elements/Map-mapbox/utils"; import Text from "@/components/elements/Text/Text"; import { FormSummaryProps } from "@/components/extensive/WizardForm/FormSummary"; import WorkdayCollapseGrid from "@/components/extensive/WorkdayCollapseGrid/WorkdayCollapseGrid"; @@ -218,7 +218,7 @@ const getEntityPolygonData = (record: any, type?: EntityName, entity?: Entity) = site: uuid } }); - return sitePolygonData ? mapPolygonData(sitePolygonData) : null; + return sitePolygonData ? parsePolygonData(sitePolygonData) : null; } else if (entityType === "projects" || entityType === "project-pitches") { const { data: projectPolygonData } = useGetV2TerrafundProjectPolygon({ queryParams: { diff --git a/src/pages/dashboard/components/ContentOverview.tsx b/src/pages/dashboard/components/ContentOverview.tsx index 0e1de930f..5949694d7 100644 --- a/src/pages/dashboard/components/ContentOverview.tsx +++ b/src/pages/dashboard/components/ContentOverview.tsx @@ -34,10 +34,11 @@ interface ContentOverviewProps { titleTable: string; textTooltipTable?: string; centroids?: DashboardGetProjectsData[]; + polygonsData?: any; } const ContentOverview = (props: ContentOverviewProps) => { - const { dataTable: data, columns, titleTable, textTooltipTable, centroids } = props; + const { dataTable: data, columns, titleTable, textTooltipTable, centroids, polygonsData } = props; const t = useT(); const modalMapFunctions = useMap(); const dashboardMapFunctions = useMap(); @@ -128,6 +129,8 @@ const ContentOverview = (props: ContentOverviewProps) => { isDashboard={"dashboard"} className="custom-popup-close-button" centroids={centroids} + showPopups={true} + polygonsData={polygonsData as Record} />
{t("PROGRAMME VIEW")} diff --git a/src/pages/dashboard/hooks/useDashboardData.ts b/src/pages/dashboard/hooks/useDashboardData.ts index bc1a86dfc..a0f0d7a61 100644 --- a/src/pages/dashboard/hooks/useDashboardData.ts +++ b/src/pages/dashboard/hooks/useDashboardData.ts @@ -5,6 +5,7 @@ import { useLoading } from "@/context/loaderAdmin.provider"; import { useGetV2DashboardActiveCountries, useGetV2DashboardActiveProjects, + useGetV2DashboardGetPolygonsStatuses, useGetV2DashboardGetProjects, useGetV2DashboardJobsCreated, useGetV2DashboardTopTreesPlanted, @@ -49,6 +50,9 @@ export const useDashboardData = (filters: any) => { const { data: centroidsDataProjects } = useGetV2DashboardGetProjects({ queryParams: queryParamsCountryProject(filters.country.country_slug, projectUuid) }); + const { data: polygonsData } = useGetV2DashboardGetPolygonsStatuses({ + queryParams: queryParamsCountryProject(filters.country.country_slug, projectUuid) + }); const [numberTreesPlanted, setNumberTreesPlanted] = useState({ value: 0, totalValue: 0 @@ -63,7 +67,6 @@ export const useDashboardData = (filters: any) => { }; setUpdateFilters(parsedFilters); }, [filters]); - const queryParams: any = useMemo(() => createQueryParams(updateFilters), [updateFilters]); const { showLoader, hideLoader } = useLoading(); const { @@ -143,6 +146,7 @@ export const useDashboardData = (filters: any) => { activeCountries, activeProjects: filteredProjects, centroidsDataProjects: centroidsDataProjects?.data, - listViewProjects + listViewProjects, + polygonsData: polygonsData?.data ?? {} }; }; diff --git a/src/pages/dashboard/index.page.tsx b/src/pages/dashboard/index.page.tsx index 182789acb..29a33c992 100644 --- a/src/pages/dashboard/index.page.tsx +++ b/src/pages/dashboard/index.page.tsx @@ -40,6 +40,7 @@ const Dashboard = () => { topProject, refetchTotalSectionHeader, centroidsDataProjects, + polygonsData, activeCountries, activeProjects } = useDashboardData(filters); @@ -393,6 +394,7 @@ const Dashboard = () => { ? "For each country, this table shows the number of projects, trees planted, hectares under restoration, and jobs created to date." : "For each project, this table shows the number of trees planted, hectares under restoration, jobs created, and volunteers engaged to date. Those with access to individual project pages can click directly on table rows to dive deep." )} + polygonsData={polygonsData} />
); From a5145cdc2c0a89d904fa52f4f70e11ac4506057e Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Tue, 22 Oct 2024 13:11:48 -0700 Subject: [PATCH 038/102] Revert "[MERGE] staging -> v3 epic" --- .storybook/preview.js | 11 - README.md | 43 +- openapi-codegen.config.ts | 388 +----------------- package.json | 14 +- src/admin/apiProvider/authProvider.ts | 82 +++- src/admin/apiProvider/utils/error.ts | 5 +- src/admin/apiProvider/utils/token.ts | 14 +- src/admin/components/App.tsx | 25 +- .../Dialogs/FormQuestionPreviewDialog.tsx | 3 +- .../Dialogs/FormSectionPreviewDialog.tsx | 3 +- .../ResourceTabs/GalleryTab/GalleryTab.tsx | 3 +- .../CommentarySection/CommentarySection.tsx | 14 +- .../PolygonDrawer/PolygonDrawer.tsx | 5 +- .../components/AttributeInformation.tsx | 3 +- .../PolygonStatus/StatusDisplay.tsx | 5 +- .../ResourceTabs/PolygonReviewTab/index.tsx | 10 +- src/admin/hooks/useGetUserRole.ts | 8 +- .../modules/form/components/CloneForm.tsx | 6 +- .../form/components/CopyFormToOtherEnv.tsx | 5 +- .../elements/Accordion/Accordion.stories.tsx | 4 +- .../ImageGallery/ImageGalleryItem.tsx | 5 +- .../DataTable/RHFCoreTeamLeadersTable.tsx | 5 +- .../DataTable/RHFFundingTypeDataTable.tsx | 5 +- .../DataTable/RHFLeadershipTeamTable.tsx | 5 +- .../DataTable/RHFOwnershipStakeTable.tsx | 5 +- .../Inputs/Dropdown/Dropdown.stories.tsx | 3 +- .../Inputs/FileInput/FilePreviewTable.tsx | 5 +- .../Inputs/FileInput/RHFFileInput.tsx | 3 +- .../elements/Inputs/Select/Select.stories.tsx | 4 +- .../SelectImage/SelectImage.stories.tsx | 4 +- .../TreeSpeciesInput.stories.tsx | 6 +- .../elements/Map-mapbox/Map.stories.tsx | 5 +- src/components/elements/Map-mapbox/Map.tsx | 8 +- .../CheckIndividualPolygonControl.tsx | 3 +- .../MapControls/CheckPolygonControl.tsx | 3 +- .../Map-mapbox/MapLayers/GeoJsonLayer.tsx | 3 +- .../Map-mapbox/components/DashboardPopup.tsx | 3 +- src/components/elements/Map-mapbox/utils.ts | 5 +- .../MapPolygonPanel/AttributeInformation.tsx | 3 +- .../MapPolygonPanel/ChecklistInformation.tsx | 3 +- .../MapPolygonPanel.stories.tsx | 6 +- .../MapSidePanel/MapSidePanel.stories.tsx | 14 +- .../EntityMapAndGalleryCard.tsx | 3 +- .../extensive/Modal/FormModal.stories.tsx | 3 +- .../extensive/Modal/Modal.stories.tsx | 6 +- .../extensive/Modal/ModalAddImages.tsx | 3 +- .../extensive/Modal/ModalImageDetails.tsx | 3 +- .../Modal/ModalWithClose.stories.tsx | 6 +- .../extensive/Modal/ModalWithLogo.stories.tsx | 6 +- .../extensive/Modal/ModalWithLogo.tsx | 14 +- .../Pagination/PerPageSelector.stories.tsx | 4 +- .../extensive/WelcomeTour/WelcomeTour.tsx | 16 +- .../WizardForm/WizardForm.stories.tsx | 9 +- src/components/extensive/WizardForm/index.tsx | 9 +- src/components/generic/Layout/MainLayout.tsx | 6 +- .../generic/Navbar/Navbar.stories.tsx | 21 +- src/components/generic/Navbar/Navbar.tsx | 9 +- .../generic/Navbar/NavbarContent.tsx | 9 +- src/components/generic/Navbar/navbarItems.ts | 12 +- src/connections/Login.ts | 43 -- src/connections/Organisation.ts | 61 --- src/connections/User.ts | 44 -- src/constants/options/frameworks.ts | 3 +- .../options/userFrameworksChoices.ts | 12 +- src/context/auth.provider.test.tsx | 51 +++ src/context/auth.provider.tsx | 59 +++ src/context/mapArea.provider.tsx | 3 +- src/generated/apiComponents.ts | 150 ------- src/generated/apiContext.ts | 5 +- src/generated/apiFetcher.ts | 17 +- src/generated/apiSchemas.ts | 152 ------- .../v3/userService/userServiceComponents.ts | 238 ----------- .../v3/userService/userServiceFetcher.ts | 6 - .../v3/userService/userServicePredicates.ts | 21 - .../v3/userService/userServiceSchemas.ts | 55 --- src/generated/v3/utils.ts | 157 ------- src/hooks/logout.ts | 8 +- src/hooks/useConnection.test.tsx | 84 ---- src/hooks/useConnection.ts | 54 --- .../useGetCustomFormSteps.stories.tsx | 5 +- src/hooks/useMessageValidations.ts | 4 +- src/hooks/useMyOrg.ts | 21 + src/hooks/useUserData.ts | 20 + src/hooks/useValueChanged.ts | 29 -- src/middleware.page.ts | 55 ++- src/middleware.test.ts | 146 ++++--- src/pages/_app.tsx | 106 +++-- src/pages/api/auth/login.tsx | 17 + src/pages/api/auth/logout.tsx | 9 + .../components/ApplicationHeader.tsx | 3 +- src/pages/auth/login/components/LoginForm.tsx | 2 +- src/pages/auth/login/index.page.tsx | 33 +- src/pages/auth/verify/email/[token].page.tsx | 3 +- src/pages/debug/index.page.tsx | 5 +- src/pages/form/[id]/pitch-select.page.tsx | 9 +- src/pages/home.page.tsx | 16 +- src/pages/my-projects/index.page.tsx | 6 +- src/pages/opportunities/index.page.tsx | 16 +- src/pages/organization/create/index.page.tsx | 6 +- .../organization/status/pending.page.tsx | 6 +- .../organization/status/rejected.page.tsx | 6 +- src/pages/site/[uuid]/tabs/Overview.tsx | 5 +- src/store/apiSlice.ts | 264 ------------ src/store/store.ts | 42 -- src/types/connection.ts | 16 - src/utils/connectionShortcuts.ts | 30 -- src/utils/geojson.ts | 2 +- src/utils/loadConnection.ts | 29 -- src/utils/log.ts | 37 -- src/utils/network.ts | 6 +- src/utils/selectorCache.ts | 29 -- src/utils/testStore.tsx | 41 -- yarn.lock | 83 +--- 113 files changed, 694 insertions(+), 2532 deletions(-) delete mode 100644 src/connections/Login.ts delete mode 100644 src/connections/Organisation.ts delete mode 100644 src/connections/User.ts create mode 100644 src/context/auth.provider.test.tsx create mode 100644 src/context/auth.provider.tsx delete mode 100644 src/generated/v3/userService/userServiceComponents.ts delete mode 100644 src/generated/v3/userService/userServiceFetcher.ts delete mode 100644 src/generated/v3/userService/userServicePredicates.ts delete mode 100644 src/generated/v3/userService/userServiceSchemas.ts delete mode 100644 src/generated/v3/utils.ts delete mode 100644 src/hooks/useConnection.test.tsx delete mode 100644 src/hooks/useConnection.ts create mode 100644 src/hooks/useMyOrg.ts create mode 100644 src/hooks/useUserData.ts delete mode 100644 src/hooks/useValueChanged.ts create mode 100644 src/pages/api/auth/login.tsx create mode 100644 src/pages/api/auth/logout.tsx delete mode 100644 src/store/apiSlice.ts delete mode 100644 src/store/store.ts delete mode 100644 src/types/connection.ts delete mode 100644 src/utils/connectionShortcuts.ts delete mode 100644 src/utils/loadConnection.ts delete mode 100644 src/utils/log.ts delete mode 100644 src/utils/selectorCache.ts delete mode 100644 src/utils/testStore.tsx diff --git a/.storybook/preview.js b/.storybook/preview.js index fe8f4a4e0..a5e2d6fba 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,6 +1,5 @@ import "src/styles/globals.css"; import * as NextImage from "next/image"; -import { StoreProvider } from "../src/utils/testStore"; export const parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, @@ -25,13 +24,3 @@ Object.defineProperty(NextImage, "default", { /> ) }); - -export const decorators = [ - (Story, options) => { - const { parameters } = options; - - return - - ; - }, -]; diff --git a/README.md b/README.md index 248d43d36..9900b4d37 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ To generate Api types/queries/fetchers/hooks, this repo uses: #### Usage -In order to generate from the v1/2 api (whenever there is some backend endpoint/type change) please use this command: +In order to generate from the api (whenever there is some backend endpoint/type change) please use this command: ``` yarn generate:api @@ -35,47 +35,6 @@ We can customize the `baseUrl` of where we are fetching from by changing the `co This is super useful if we want to globally set some headers for each request (such as Authorization header). It exposes a component hook so we can use other hooks (such as Auth hook or context) to get the logged in token for example and inject it in the global request context. -##### v3 API -The V3 API has a different API layer, but the generation is similar: -``` -yarn generate:services -``` - -When adding a new **service** app to the v3 API, a few steps are needed to integrate it: -* In `openapi-codegen.config.ts`, add the new service name to the `SERVICES` array (e.g. `foo-service`). -* This will generate a new target, which needs to be added to `package.json`: - * Under scripts, add `"generate:fooService": "npm run generate:fooService"` - * Under the `"generate:services"` script, add the new service: `"generate:services": "npm run generate:userService && npm run generate:fooService` -* After running `yarn generate:fooService` the first time, open the generated `fooServiceFetcher.ts` and - modify it to match `userServiceFetcher.ts`. - * This file does not get regenerated after the first time, and so it can utilize the same utilities - for interfacing with the redux API layer / connection system that the other v3 services use. - -When adding a new **resource** to the v3 API, a couple of steps are needed to integrate it: -* The resource needs to be specified in shape of the redux API store. In `apiSlice.ts`, add the new - resource plural name (the `type` returned in the API responses) to the store by adding it to the - `RESOURCES` const. This will make sure it's listed in the type of the ApiStore so that resources that match that type are seamlessly folded into the store cache structure. -* The shape of the resource should be specified by the auto-generated API. This type needs to be - added to the `ApiResource` type in `apiSlice.ts`. This allows us to have strongly typed results - coming from the redux APi store. - -### Connections -Connections are a **declarative** way for components to get access to the data from the cached API -layer that they need. This system is under development, and the current documentation about it is -[available in Confluence](https://gfw.atlassian.net/wiki/spaces/TerraMatch/pages/1423147024/Connections) - -Note for Storybook: Because multiple storybook components can be on the page at the same time that each -have their own copy of the redux store, the Connection utilities `loadConnection` (typically used -via `connectionLoaded` in `connectionShortcuts.ts`) and `connectionSelector` will not work as expected -in storybook stories. This is because those utilities rely on `ApiSlice.redux` and `ApiSlice.apiDataStore`, -and in the case of storybook, those will end up with only the redux store from the last component on the -page. Regular connection use through `useConnection` will work because it gets the store from the -Provider in the redux component tree in that case. - -When building storybook stories for components that rely on connections via `useConnection`, make sure -that the story is provided with a store that has all dependent data already loaded. See `testStore.tsx`'s -`buildStore` builder, and `Navbar.stories.tsx` for example usage. - ## Translation ([Transifex Native SDK](https://developers.transifex.com/docs/native)). Transifex native sdk provides a simple solution for internationalization. diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 2d97a2d6a..56dbd6121 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -1,32 +1,9 @@ -/* eslint-disable no-case-declarations */ import { defineConfig } from "@openapi-codegen/cli"; -import { Config } from "@openapi-codegen/cli/lib/types"; -import { generateFetchers, generateReactQueryComponents, generateSchemaTypes } from "@openapi-codegen/typescript"; -import { ConfigBase, Context } from "@openapi-codegen/typescript/lib/generators/types"; -import c from "case"; +import { generateReactQueryComponents, generateSchemaTypes } from "@openapi-codegen/typescript"; import dotenv from "dotenv"; -import _ from "lodash"; -import { - ComponentsObject, - isReferenceObject, - OpenAPIObject, - OperationObject, - ParameterObject, - PathItemObject -} from "openapi3-ts"; -import ts from "typescript"; - -const f = ts.factory; - dotenv.config(); -// The services defined in the v3 Node BE codebase. Although the URL path for APIs in the v3 space -// are namespaced by feature set rather than service (a service may contain multiple namespaces), we -// isolate the generated API integration by service to make it easier for a developer to find where -// the associated BE code is for a given FE API integration. -const SERVICES = ["user-service"]; - -const config: Record = { +export default defineConfig({ api: { from: { source: "url", @@ -36,21 +13,26 @@ const config: Record = { to: async context => { let paths = context.openAPIDocument.paths; let newPaths: any = {}; + //! Treat carefully this might potentially break the api generation - // This Logic will make sure every single endpoint has a `operationId` key (needed to generate endpoints) - Object.keys(paths).forEach(k => { + // This Logic will make sure every sigle endpoint has a `operationId` key (needed to generate endpoints) + Object.keys(paths).forEach((k, i) => { newPaths[k] = {}; const eps = Object.keys(paths[k]).filter(ep => ep !== "parameters"); - eps.forEach(ep => { + + eps.forEach((ep, i) => { const current = paths[k][ep]; const operationId = ep + k.replaceAll("/", "-").replaceAll("{", "").replaceAll("}", ""); + newPaths[k][ep] = { ...current, operationId }; }); }); + context.openAPIDocument.paths = newPaths; + const filenamePrefix = "api"; const { schemasFiles } = await generateSchemaTypes(context, { filenamePrefix @@ -61,352 +43,4 @@ const config: Record = { }); } } -}; - -for (const service of SERVICES) { - const name = _.camelCase(service); - config[name] = { - from: { - source: "url", - url: `${process.env.NEXT_PUBLIC_API_BASE_URL}/${service}/documentation/api-json` - }, - outputDir: `src/generated/v3/${name}`, - to: async context => { - const { schemasFiles } = await generateSchemaTypes(context, { filenamePrefix: name }); - await generateFetchers(context, { filenamePrefix: name, schemasFiles }); - await generatePendingPredicates(context, { filenamePrefix: name }); - } - }; -} - -export default defineConfig(config); - -/** - * Generates Connection predicates for checking if a given request is in progress or failed. - * - * Based on generators from https://github.com/fabien0102/openapi-codegen/blob/main/plugins/typescript. Many of the - * methods here are similar to ones in that repo, but they aren't exported, so were copied from there and modified for - * use in this generator. - */ -const generatePendingPredicates = async (context: Context, config: ConfigBase) => { - const sourceFile = ts.createSourceFile("index.ts", "", ts.ScriptTarget.Latest); - - const printer = ts.createPrinter({ - newLine: ts.NewLineKind.LineFeed, - removeComments: false - }); - - const printNodes = (nodes: ts.Node[]) => - nodes - .map((node: ts.Node, i, nodes) => { - return ( - printer.printNode(ts.EmitHint.Unspecified, node, sourceFile) + - (ts.isJSDoc(node) || (ts.isImportDeclaration(node) && nodes[i + 1] && ts.isImportDeclaration(nodes[i + 1])) - ? "" - : "\n") - ); - }) - .join("\n"); - - const filenamePrefix = c.snake(config.filenamePrefix ?? context.openAPIDocument.info.title) + "-"; - const formatFilename = config.filenameCase ? c[config.filenameCase] : c.camel; - const filename = formatFilename(filenamePrefix + "-predicates"); - const nodes: ts.Node[] = []; - const componentImports: string[] = []; - - let variablesExtraPropsType: ts.TypeNode = f.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword); - - Object.entries(context.openAPIDocument.paths).forEach(([route, verbs]: [string, PathItemObject]) => { - Object.entries(verbs).forEach(([verb, operation]) => { - if (!isVerb(verb) || !isOperationObject(operation)) return; - - const operationId = c.camel(operation.operationId); - const { pathParamsType, variablesType, queryParamsType } = getOperationTypes({ - openAPIDocument: context.openAPIDocument, - operation, - operationId, - pathParameters: verbs.parameters, - variablesExtraPropsType - }); - - for (const type of [pathParamsType, queryParamsType, variablesType]) { - if (ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName)) { - componentImports.push(type.typeName.text); - } - } - - nodes.push( - ...createPredicateNodes({ - pathParamsType, - variablesType, - queryParamsType, - url: route, - verb, - name: operationId - }) - ); - }); - }); - - await context.writeFile( - filename + ".ts", - printNodes([ - createNamedImport(["isFetching", "fetchFailed"], `../utils`), - createNamedImport(["ApiDataStore"], "@/store/apiSlice"), - ...(componentImports.length == 0 - ? [] - : [createNamedImport(componentImports, `./${formatFilename(filenamePrefix + "-components")}`)]), - ...nodes - ]) - ); -}; - -const camelizedPathParams = (url: string) => url.replace(/\{\w*}/g, match => `{${c.camel(match)}}`); - -const createPredicateNodes = ({ - queryParamsType, - pathParamsType, - variablesType, - url, - verb, - name -}: { - pathParamsType: ts.TypeNode; - queryParamsType: ts.TypeNode; - variablesType: ts.TypeNode; - url: string; - verb: string; - name: string; -}) => { - const nodes: ts.Node[] = []; - - const storeTypeDeclaration = f.createParameterDeclaration( - undefined, - undefined, - f.createIdentifier("store"), - undefined, - f.createTypeReferenceNode("ApiDataStore"), - undefined - ); - - nodes.push( - ...["isFetching", "fetchFailed"].map(fnName => { - const callBaseSelector = f.createCallExpression( - f.createIdentifier(fnName), - [queryParamsType, pathParamsType], - [ - f.createObjectLiteralExpression( - [ - f.createShorthandPropertyAssignment("store"), - f.createPropertyAssignment(f.createIdentifier("url"), f.createStringLiteral(camelizedPathParams(url))), - f.createPropertyAssignment(f.createIdentifier("method"), f.createStringLiteral(verb)), - ...(variablesType.kind !== ts.SyntaxKind.VoidKeyword - ? [f.createSpreadAssignment(f.createIdentifier("variables"))] - : []) - ], - false - ) - ] - ); - - let selector = f.createArrowFunction( - undefined, - undefined, - [storeTypeDeclaration], - undefined, - f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - callBaseSelector - ); - - if (variablesType.kind !== ts.SyntaxKind.VoidKeyword) { - selector = f.createArrowFunction( - undefined, - undefined, - [ - f.createParameterDeclaration( - undefined, - undefined, - f.createIdentifier("variables"), - undefined, - variablesType, - undefined - ) - ], - undefined, - f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - selector - ); - } - - return f.createVariableStatement( - [f.createModifier(ts.SyntaxKind.ExportKeyword)], - f.createVariableDeclarationList( - [ - f.createVariableDeclaration( - f.createIdentifier(`${name}${_.upperFirst(fnName)}`), - undefined, - undefined, - selector - ) - ], - ts.NodeFlags.Const - ) - ); - }) - ); - - return nodes; -}; - -const isVerb = (verb: string): verb is "get" | "post" | "patch" | "put" | "delete" => - ["get", "post", "patch", "put", "delete"].includes(verb); - -const isOperationObject = (obj: any): obj is OperationObject & { operationId: string } => - typeof obj === "object" && typeof (obj as any).operationId === "string"; - -export type GetOperationTypesOptions = { - operationId: string; - operation: OperationObject; - openAPIDocument: OpenAPIObject; - pathParameters?: PathItemObject["parameters"]; - variablesExtraPropsType: ts.TypeNode; -}; - -export type GetOperationTypesOutput = { - pathParamsType: ts.TypeNode; - variablesType: ts.TypeNode; - queryParamsType: ts.TypeNode; -}; - -const getParamsGroupByType = (parameters: OperationObject["parameters"] = [], components: ComponentsObject = {}) => { - const { query: queryParams = [] as ParameterObject[], path: pathParams = [] as ParameterObject[] } = _.groupBy( - [...parameters].map(p => { - if (isReferenceObject(p)) { - const schema = _.get(components, p.$ref.replace("#/components/", "").replace("/", ".")); - if (!schema) { - throw new Error(`${p.$ref} not found!`); - } - return schema; - } else { - return p; - } - }), - "in" - ); - - return { queryParams, pathParams }; -}; - -export const getVariablesType = ({ - pathParamsType, - pathParamsOptional, - queryParamsType, - queryParamsOptional -}: { - pathParamsType: ts.TypeNode; - pathParamsOptional: boolean; - queryParamsType: ts.TypeNode; - queryParamsOptional: boolean; -}) => { - const variablesItems: ts.TypeElement[] = []; - - const hasProperties = (node: ts.Node) => { - return (!ts.isTypeLiteralNode(node) || node.members.length > 0) && node.kind !== ts.SyntaxKind.UndefinedKeyword; - }; - - if (hasProperties(pathParamsType)) { - variablesItems.push( - f.createPropertySignature( - undefined, - f.createIdentifier("pathParams"), - pathParamsOptional ? f.createToken(ts.SyntaxKind.QuestionToken) : undefined, - pathParamsType - ) - ); - } - if (hasProperties(queryParamsType)) { - variablesItems.push( - f.createPropertySignature( - undefined, - f.createIdentifier("queryParams"), - queryParamsOptional ? f.createToken(ts.SyntaxKind.QuestionToken) : undefined, - queryParamsType - ) - ); - } - - return variablesItems.length === 0 - ? f.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword) - : f.createTypeLiteralNode(variablesItems); -}; - -const getOperationTypes = ({ - operationId, - operation, - openAPIDocument, - pathParameters = [], - variablesExtraPropsType -}: GetOperationTypesOptions): GetOperationTypesOutput => { - // Generate params types - const { pathParams, queryParams } = getParamsGroupByType( - [...pathParameters, ...(operation.parameters || [])], - openAPIDocument.components - ); - - const pathParamsOptional = pathParams.reduce((mem, p) => { - return mem && !p.required; - }, true); - const queryParamsOptional = queryParams.reduce((mem, p) => { - return mem && !p.required; - }, true); - - const pathParamsType = - pathParams.length > 0 - ? f.createTypeReferenceNode(`${c.pascal(operationId)}PathParams`) - : f.createTypeLiteralNode([]); - - const queryParamsType = - queryParams.length > 0 - ? f.createTypeReferenceNode(`${c.pascal(operationId)}QueryParams`) - : f.createTypeLiteralNode([]); - - const variablesIdentifier = c.pascal(`${operationId}Variables`); - - let variablesType: ts.TypeNode = getVariablesType({ - pathParamsType, - queryParamsType, - pathParamsOptional, - queryParamsOptional - }); - - if (variablesExtraPropsType.kind !== ts.SyntaxKind.VoidKeyword) { - variablesType = - variablesType.kind === ts.SyntaxKind.VoidKeyword - ? variablesExtraPropsType - : f.createIntersectionTypeNode([variablesType, variablesExtraPropsType]); - } - - if (variablesType.kind !== ts.SyntaxKind.VoidKeyword) { - variablesType = f.createTypeReferenceNode(variablesIdentifier); - } - - return { - pathParamsType, - queryParamsType, - variablesType - }; -}; - -const createNamedImport = (fnName: string | string[], filename: string, isTypeOnly = false) => { - const fnNames = Array.isArray(fnName) ? fnName : [fnName]; - return f.createImportDeclaration( - undefined, - f.createImportClause( - isTypeOnly, - undefined, - f.createNamedImports(fnNames.map(name => f.createImportSpecifier(false, undefined, f.createIdentifier(name)))) - ), - f.createStringLiteral(filename), - undefined - ); -}; +}); diff --git a/package.json b/package.json index 79c6d94e3..aa764d798 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "postinstall": "patch-package", "dev": "next dev", - "build": "npm run generate:api && npm run generate:services && next build", + "build": "npm run generate:api && next build", "start": "next start", "test": "jest --watch", "test:coverage": "jest --coverage", @@ -15,9 +15,7 @@ "prepare": "husky install", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "generate:api": "openapi-codegen gen api", - "generate:userService": "openapi-codegen gen userService", - "generate:services": "npm run generate:userService", + "generate:api": "npx openapi-codegen gen api", "tx:push": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli push --key-generator=hash src/ --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET", "tx:pull": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli pull --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET" }, @@ -32,7 +30,6 @@ "@mui/icons-material": "^5.11.0", "@mui/material": "^5.11.7", "@mui/x-data-grid": "^6.16.1", - "@reduxjs/toolkit": "^2.2.7", "@sentry/nextjs": "^7.109.0", "@tailwindcss/forms": "^0.5.3", "@tanstack/react-query": "^4.23.0", @@ -44,7 +41,6 @@ "@transifex/react": "^5.0.6", "@turf/bbox": "^6.5.0", "canvg": "^4.0.1", - "case": "^1.6.3", "circle-to-polygon": "^2.2.0", "classnames": "^2.3.2", "date-fns": "^2.29.3", @@ -60,7 +56,6 @@ "mapbox-gl": "^2.15.0", "mapbox-gl-draw-circle": "^1.1.2", "next": "13.1.5", - "next-redux-wrapper": "^8.1.0", "nookies": "^2.5.2", "prettier-plugin-tailwindcss": "^0.2.2", "ra-input-rich-text": "^4.12.2", @@ -74,9 +69,6 @@ "react-inlinesvg": "^3.0.0", "react-joyride": "^2.5.5", "recharts": "^2.13.0", - "react-redux": "^9.1.2", - "redux-logger": "^3.0.6", - "reselect": "^4.1.8", "swiper": "^9.0.5", "tailwind-merge": "^1.14.0", "typescript": "4.9.4", @@ -108,11 +100,9 @@ "@types/mapbox-gl": "^2.7.13", "@types/mapbox__mapbox-gl-draw": "^1.4.1", "@types/node": "18.11.18", - "@types/pluralize": "^0.0.33", "@types/react": "18.0.27", "@types/react-dom": "18.0.10", "@types/react-test-renderer": "^18.0.0", - "@types/redux-logger": "^3.0.13", "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^5.10.2", "@typescript-eslint/parser": "^5.10.2", diff --git a/src/admin/apiProvider/authProvider.ts b/src/admin/apiProvider/authProvider.ts index c666ce58c..3b41fb675 100644 --- a/src/admin/apiProvider/authProvider.ts +++ b/src/admin/apiProvider/authProvider.ts @@ -1,36 +1,82 @@ import { AuthProvider } from "react-admin"; -import { isAdmin, UserRole } from "@/admin/apiProvider/utils/user"; -import { loadLogin, logout } from "@/connections/Login"; -import { loadMyUser } from "@/connections/User"; -import Log from "@/utils/log"; +import { isAdmin } from "@/admin/apiProvider/utils/user"; +import { fetchGetAuthLogout, fetchGetAuthMe, fetchPostAuthLogin } from "@/generated/apiComponents"; + +import { AdminTokenStorageKey, removeAccessToken, setAccessToken } from "./utils/token"; export const authProvider: AuthProvider = { - login: async () => { - Log.error("Admin app does not support direct login"); + // send username and password to the auth server and get back credentials + login: params => { + return fetchPostAuthLogin({ body: { email_address: params.username, password: params.password } }) + .then(async res => { + //@ts-ignore + const token = res.data.token; + + setAccessToken(token); + }) + .catch(e => { + console.log(e); + throw Error("Wrong username or password"); + }); }, - checkError: async () => {}, + // when the dataProvider returns an error, check if this is an authentication error + checkError: () => { + return Promise.resolve(); + }, // when the user navigates, make sure that their credentials are still valid - checkAuth: async () => { - const { user } = await loadMyUser(); - if (user == null) throw "No user logged in."; + checkAuth: async params => { + const token = localStorage.getItem(AdminTokenStorageKey); + if (!token) return Promise.reject(); - if (!isAdmin(user.primaryRole as UserRole)) throw "Only admins are allowed."; + return new Promise((resolve, reject) => { + fetchGetAuthMe({}) + .then(res => { + //@ts-ignore + if (isAdmin(res.data.role)) resolve(); + else reject("Only admins are allowed."); + }) + .catch(() => reject()); + }); }, - - // remove local credentials + // remove local credentials and notify the auth server that the user logged out logout: async () => { - const { isLoggedIn } = await loadLogin(); - if (isLoggedIn) logout(); + const token = localStorage.getItem(AdminTokenStorageKey); + if (!token) return Promise.resolve(); + + return new Promise(resolve => { + fetchGetAuthLogout({}) + .then(async () => { + removeAccessToken(); + window.location.replace("/auth/login"); + }) + .catch(() => { + resolve(); + }); + }); }, + // get the user's profile getIdentity: async () => { - const { user } = await loadMyUser(); - if (user == null) throw "No user logged in."; + const token = localStorage.getItem(AdminTokenStorageKey); + if (!token) return Promise.reject(); - return { id: user.uuid, fullName: user.fullName, primaryRole: user.primaryRole }; + return new Promise((resolve, reject) => { + fetchGetAuthMe({}) + .then(response => { + //@ts-ignore + const userData = response.data; + resolve({ + ...userData, + fullName: `${userData.first_name} ${userData.last_name}` + }); + }) + .catch(() => { + reject(); + }); + }); }, // get the user permissions (optional) diff --git a/src/admin/apiProvider/utils/error.ts b/src/admin/apiProvider/utils/error.ts index 7a1841e22..204945203 100644 --- a/src/admin/apiProvider/utils/error.ts +++ b/src/admin/apiProvider/utils/error.ts @@ -1,9 +1,10 @@ +import * as Sentry from "@sentry/nextjs"; import { HttpError } from "react-admin"; import { ErrorWrapper } from "@/generated/apiFetcher"; -import Log from "@/utils/log"; export const getFormattedErrorForRA = (err: ErrorWrapper) => { - Log.error("Network error", err?.statusCode, ...(err?.errors ?? [])); + console.log(err); + Sentry.captureException(err); return new HttpError(err?.errors?.map?.(e => e.detail).join(", ") || "", err?.statusCode); }; diff --git a/src/admin/apiProvider/utils/token.ts b/src/admin/apiProvider/utils/token.ts index 7867699ee..80c1931d2 100644 --- a/src/admin/apiProvider/utils/token.ts +++ b/src/admin/apiProvider/utils/token.ts @@ -1,14 +1,12 @@ import { destroyCookie, setCookie } from "nookies"; -const TOKEN_STORAGE_KEY = "access_token"; -const COOKIE_STORAGE_KEY = "accessToken"; +export const AdminTokenStorageKey = "access_token"; +export const AdminCookieStorageKey = "accessToken"; const MiddlewareCacheKey = "middlewareCache"; -export const getAccessToken = () => localStorage.getItem(TOKEN_STORAGE_KEY); - export const setAccessToken = (token: string) => { - localStorage.setItem(TOKEN_STORAGE_KEY, token); - setCookie(null, COOKIE_STORAGE_KEY, token, { + localStorage.setItem(AdminTokenStorageKey, token); + setCookie(null, AdminCookieStorageKey, token, { maxAge: 60 * 60 * 12, // 12 hours secure: process.env.NODE_ENV !== "development", path: "/" @@ -16,8 +14,8 @@ export const setAccessToken = (token: string) => { }; export const removeAccessToken = () => { - localStorage.removeItem(TOKEN_STORAGE_KEY); - destroyCookie(null, COOKIE_STORAGE_KEY, { + localStorage.removeItem(AdminTokenStorageKey); + destroyCookie(null, AdminCookieStorageKey, { path: "/" }); destroyCookie(null, MiddlewareCacheKey, { diff --git a/src/admin/components/App.tsx b/src/admin/components/App.tsx index a3b4e8db5..d073f8c98 100644 --- a/src/admin/components/App.tsx +++ b/src/admin/components/App.tsx @@ -1,4 +1,6 @@ import SummarizeIcon from "@mui/icons-material/Summarize"; +import router from "next/router"; +import { useEffect, useState } from "react"; import { Admin, Resource } from "react-admin"; import { authProvider } from "@/admin/apiProvider/authProvider"; @@ -6,18 +8,31 @@ import { dataProvider } from "@/admin/apiProvider/dataProviders"; import { AppLayout } from "@/admin/components/AppLayout"; import { theme } from "@/admin/components/theme"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; -import { useMyUser } from "@/connections/User"; import { LoadingProvider } from "@/context/loaderAdmin.provider"; import LoginPage from "@/pages/auth/login/index.page"; import modules from "../modules"; const App = () => { - const [, { user }] = useMyUser(); - if (user == null) return null; + const [identity, setIdentity] = useState(null); - const canCreate = user.primaryRole === "admin-super"; - const isAdmin = user.primaryRole.includes("admin"); + useEffect(() => { + const getIdentity = async () => { + try { + const data: any = await authProvider?.getIdentity?.(); + setIdentity(data); + } catch (error) { + router.push("/auth/login"); + } + }; + + getIdentity(); + }, []); + + if (identity == null) return null; + + const canCreate = identity.role === "admin-super"; + const isAdmin = identity.role?.includes("admin"); return ( - Log.debug("Field Mapper onChange")} /> + diff --git a/src/admin/components/Dialogs/FormSectionPreviewDialog.tsx b/src/admin/components/Dialogs/FormSectionPreviewDialog.tsx index 05c3cdefb..c2b05f29f 100644 --- a/src/admin/components/Dialogs/FormSectionPreviewDialog.tsx +++ b/src/admin/components/Dialogs/FormSectionPreviewDialog.tsx @@ -18,7 +18,6 @@ import { FormStep } from "@/components/extensive/WizardForm/FormStep"; import ModalProvider from "@/context/modal.provider"; import { FormSectionRead, V2GenericList } from "@/generated/apiSchemas"; import { apiFormSectionToFormStep } from "@/helpers/customForms"; -import Log from "@/utils/log"; interface ConfirmationDialogProps extends DialogProps { section?: FormSectionRead; @@ -50,7 +49,7 @@ export const FormSectionPreviewDialog = ({ linkedFieldData, section: _section, . - Log.debug("FormStep onChange")} /> + diff --git a/src/admin/components/ResourceTabs/GalleryTab/GalleryTab.tsx b/src/admin/components/ResourceTabs/GalleryTab/GalleryTab.tsx index 2fbac0fec..7e32e558d 100644 --- a/src/admin/components/ResourceTabs/GalleryTab/GalleryTab.tsx +++ b/src/admin/components/ResourceTabs/GalleryTab/GalleryTab.tsx @@ -14,7 +14,6 @@ import { useModalContext } from "@/context/modal.provider"; import { useDeleteV2FilesUUID, useGetV2MODELUUIDFiles } from "@/generated/apiComponents"; import { getCurrentPathEntity } from "@/helpers/entity"; import { EntityName, FileType } from "@/types/common"; -import Log from "@/utils/log"; interface IProps extends Omit { label?: string; @@ -99,7 +98,7 @@ const GalleryTab: FC = ({ label, entity, ...rest }) => { collection="media" entityData={ctx?.record} setErrorMessage={message => { - Log.error(message); + console.error(message); }} /> ); diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx index a11f6f395..15ec3b9e2 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx @@ -3,7 +3,7 @@ import { When } from "react-if"; import CommentaryBox from "@/components/elements/CommentaryBox/CommentaryBox"; import Text from "@/components/elements/Text/Text"; import Loader from "@/components/generic/Loading/Loader"; -import { useMyUser } from "@/connections/User"; +import { useGetAuthMe } from "@/generated/apiComponents"; import { AuditLogEntity } from "../../../AuditLogTab/constants/types"; @@ -20,14 +20,20 @@ const CommentarySection = ({ viewCommentsList?: boolean; loading?: boolean; }) => { - const [, { user }] = useMyUser(); + const { data: authMe } = useGetAuthMe({}) as { + data: { + data: any; + first_name: string; + last_name: string; + }; + }; return (
Send Comment { - Log.error("Error clipping polygons:", error); + console.error("Error clipping polygons:", error); openNotification("error", t("Error! Could not fix polygons"), t("Please try again later.")); } }); @@ -259,7 +258,7 @@ const PolygonDrawer = ({ showLoader(); clipPolygons({ pathParams: { uuid: polygonSelected } }); } else { - Log.error("Polygon UUID is missing"); + console.error("Polygon UUID is missing"); openNotification("error", t("Error"), t("Cannot fix polygons: Polygon UUID is missing.")); } }; diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/AttributeInformation.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/AttributeInformation.tsx index 9ebc420de..f3756d86e 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/AttributeInformation.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/AttributeInformation.tsx @@ -14,7 +14,6 @@ import { usePostV2TerrafundNewSitePolygonUuidNewVersion } from "@/generated/apiComponents"; import { SitePolygon, SitePolygonsDataResponse } from "@/generated/apiSchemas"; -import Log from "@/utils/log"; const dropdownOptionsRestoration = [ { @@ -212,7 +211,7 @@ const AttributeInformation = ({ } ); } catch (error) { - Log.error("Error creating polygon version:", error); + console.error("Error creating polygon version:", error); } } const response = (await fetchGetV2SitePolygonUuid({ diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonStatus/StatusDisplay.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonStatus/StatusDisplay.tsx index 667b976af..7e39d0070 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonStatus/StatusDisplay.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonStatus/StatusDisplay.tsx @@ -7,7 +7,6 @@ import ModalConfirm from "@/components/extensive/Modal/ModalConfirm"; import { ModalId } from "@/components/extensive/Modal/ModalConst"; import { useModalContext } from "@/context/modal.provider"; import { useNotificationContext } from "@/context/notification.provider"; -import Log from "@/utils/log"; import { AuditLogEntity, AuditLogEntityEnum } from "../../../AuditLogTab/constants/types"; import { getRequestPathParam } from "../../../AuditLogTab/utils/util"; @@ -272,7 +271,7 @@ const StatusDisplay = ({ "The request encountered an issue, or the comment exceeds 255 characters." ); - Log.error("The request encountered an issue", e); + console.error(e); } finally { onFinallyRequest(); } @@ -308,7 +307,7 @@ const StatusDisplay = ({ "Error!", "The request encountered an issue, or the comment exceeds 255 characters." ); - Log.error("Request encountered an issue", e); + console.error(e); } finally { onFinallyRequest(); } diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx index 749c51e3b..eaeaa9514 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx @@ -21,7 +21,8 @@ import { MENU_PLACEMENT_RIGHT_BOTTOM, MENU_PLACEMENT_RIGHT_TOP } from "@/compone import Table from "@/components/elements/Table/Table"; import { VARIANT_TABLE_SITE_POLYGON_REVIEW } from "@/components/elements/Table/TableVariants"; import Text from "@/components/elements/Text/Text"; -import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; +import Icon from "@/components/extensive/Icon/Icon"; +import { IconNames } from "@/components/extensive/Icon/Icon"; import ModalAdd from "@/components/extensive/Modal/ModalAdd"; import ModalConfirm from "@/components/extensive/Modal/ModalConfirm"; import { ModalId } from "@/components/extensive/Modal/ModalConst"; @@ -49,7 +50,6 @@ import { SitePolygonsLoadedDataResponse } from "@/generated/apiSchemas"; import { EntityName, FileType, UploadedFile } from "@/types/common"; -import Log from "@/utils/log"; import ModalIdentified from "../../extensive/Modal/ModalIdentified"; import AddDataButton from "./components/AddDataButton"; @@ -219,7 +219,7 @@ const PolygonReviewTab: FC = props => { linear: false }); } else { - Log.error("Bounding box is not in the expected format"); + console.error("Bounding box is not in the expected format"); } }; @@ -236,7 +236,7 @@ const PolygonReviewTab: FC = props => { } }) .catch(error => { - Log.error("Error deleting polygon:", error); + console.error("Error deleting polygon:", error); }); }; @@ -382,7 +382,7 @@ const PolygonReviewTab: FC = props => { openNotification("success", "Success, Your Polygons were approved!", ""); refetch(); } catch (error) { - Log.error("Polygon approval error", error); + console.error(error); } }} /> diff --git a/src/admin/hooks/useGetUserRole.ts b/src/admin/hooks/useGetUserRole.ts index f9d485a73..b3e6ace93 100644 --- a/src/admin/hooks/useGetUserRole.ts +++ b/src/admin/hooks/useGetUserRole.ts @@ -5,9 +5,9 @@ export const useGetUserRole = () => { const user: any = data || {}; return { - role: user.primaryRole, - isSuperAdmin: user.primaryRole === "admin-super", - isPPCAdmin: user.primaryRole === "admin-ppc", - isPPCTerrafundAdmin: user.primaryRole === "admin-terrafund" + role: user.role, + isSuperAdmin: user.role === "admin-super", + isPPCAdmin: user.role === "admin-ppc", + isPPCTerrafundAdmin: user.role === "admin-terrafund" }; }; diff --git a/src/admin/modules/form/components/CloneForm.tsx b/src/admin/modules/form/components/CloneForm.tsx index e91755ea1..104750f2c 100644 --- a/src/admin/modules/form/components/CloneForm.tsx +++ b/src/admin/modules/form/components/CloneForm.tsx @@ -4,7 +4,7 @@ import { useNotify, useRecordContext } from "react-admin"; import { useForm } from "react-hook-form"; import { normalizeFormCreatePayload } from "@/admin/apiProvider/dataNormalizers/formDataNormalizer"; -import { getAccessToken } from "@/admin/apiProvider/utils/token"; +import { AdminTokenStorageKey } from "@/admin/apiProvider/utils/token"; import { appendAdditionalFormQuestionFields } from "@/admin/modules/form/components/FormBuilder/QuestionArrayInput"; import Input from "@/components/elements/Inputs/Input/Input"; import { fetchGetV2FormsLinkedFieldListing } from "@/generated/apiComponents"; @@ -12,7 +12,7 @@ import { fetchGetV2FormsLinkedFieldListing } from "@/generated/apiComponents"; export const CloneForm = () => { const record: any = useRecordContext(); const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; - const token = getAccessToken(); + const token = localStorage.getItem(AdminTokenStorageKey); const [open, setOpen] = useState(false); const notify = useNotify(); const formHook = useForm({ @@ -90,7 +90,7 @@ export const CloneForm = () => { - diff --git a/src/admin/modules/form/components/CopyFormToOtherEnv.tsx b/src/admin/modules/form/components/CopyFormToOtherEnv.tsx index 20e76da82..18b86d72c 100644 --- a/src/admin/modules/form/components/CopyFormToOtherEnv.tsx +++ b/src/admin/modules/form/components/CopyFormToOtherEnv.tsx @@ -8,7 +8,6 @@ import { appendAdditionalFormQuestionFields } from "@/admin/modules/form/compone import RHFDropdown from "@/components/elements/Inputs/Dropdown/RHFDropdown"; import Input from "@/components/elements/Inputs/Input/Input"; import { fetchGetV2FormsLinkedFieldListing } from "@/generated/apiComponents"; -import Log from "@/utils/log"; const envOptions = [ { @@ -40,7 +39,7 @@ export const CopyFormToOtherEnv = () => { } }); const { register, handleSubmit, formState, getValues } = formHook; - Log.info(getValues(), formState.errors); + console.log(getValues(), formState.errors); const copyToDestinationEnv = async ({ env: baseUrl, title: formTitle, framework_key, ...body }: any) => { const linkedFieldsData: any = await fetchGetV2FormsLinkedFieldListing({}); @@ -51,7 +50,7 @@ export const CopyFormToOtherEnv = () => { }, body: JSON.stringify(body) }); - Log.debug("Login response", loginResp); + console.log(loginResp); if (loginResp.status !== 200) { return notify("wrong username password", { type: "error" }); diff --git a/src/components/elements/Accordion/Accordion.stories.tsx b/src/components/elements/Accordion/Accordion.stories.tsx index c5c5b441e..83f3cc134 100644 --- a/src/components/elements/Accordion/Accordion.stories.tsx +++ b/src/components/elements/Accordion/Accordion.stories.tsx @@ -1,7 +1,5 @@ import { Meta, StoryObj } from "@storybook/react"; -import Log from "@/utils/log"; - import Accordion from "./Accordion"; const meta: Meta = { @@ -35,7 +33,7 @@ export const WithCTA: Story = { ...Default.args, ctaButtonProps: { text: "Edit", - onClick: () => Log.info("CTA clicked") + onClick: console.log } } }; diff --git a/src/components/elements/ImageGallery/ImageGalleryItem.tsx b/src/components/elements/ImageGallery/ImageGalleryItem.tsx index ae52effca..80809a089 100644 --- a/src/components/elements/ImageGallery/ImageGalleryItem.tsx +++ b/src/components/elements/ImageGallery/ImageGalleryItem.tsx @@ -14,7 +14,6 @@ import { useNotificationContext } from "@/context/notification.provider"; import { usePatchV2MediaProjectProjectMediaUuid, usePostV2ExportImage } from "@/generated/apiComponents"; import { useGetReadableEntityName } from "@/hooks/entity/useGetReadableEntityName"; import { SingularEntityName } from "@/types/common"; -import Log from "@/utils/log"; import ImageWithChildren from "../ImageWithChildren/ImageWithChildren"; import Menu from "../Menu/Menu"; @@ -98,7 +97,7 @@ const ImageGalleryItem: FC = ({ }); if (!response) { - Log.error("No response received from the server."); + console.error("No response received from the server."); openNotification("error", t("Error!"), t("No response received from the server.")); return; } @@ -117,7 +116,7 @@ const ImageGalleryItem: FC = ({ hideLoader(); openNotification("success", t("Success!"), t("Image downloaded successfully")); } catch (error) { - Log.error("Download error:", error); + console.error("Download error:", error); hideLoader(); } }; diff --git a/src/components/elements/Inputs/DataTable/RHFCoreTeamLeadersTable.tsx b/src/components/elements/Inputs/DataTable/RHFCoreTeamLeadersTable.tsx index 1a388cfb3..9d5eaae16 100644 --- a/src/components/elements/Inputs/DataTable/RHFCoreTeamLeadersTable.tsx +++ b/src/components/elements/Inputs/DataTable/RHFCoreTeamLeadersTable.tsx @@ -5,9 +5,9 @@ import { useController, UseControllerProps, UseFormReturn } from "react-hook-for import * as yup from "yup"; import { FieldType } from "@/components/extensive/WizardForm/types"; -import { useMyOrg } from "@/connections/Organisation"; import { getGenderOptions } from "@/constants/options/gender"; import { useDeleteV2CoreTeamLeaderUUID, usePostV2CoreTeamLeader } from "@/generated/apiComponents"; +import { useMyOrg } from "@/hooks/useMyOrg"; import { formatOptionsList } from "@/utils/options"; import DataTable, { DataTableProps } from "./DataTable"; @@ -45,7 +45,8 @@ const RHFCoreTeamLeadersDataTable = ({ const { field } = useController(props); const value = field?.value || []; - const [, { organisationId }] = useMyOrg(); + const myOrg = useMyOrg(); + const organisationId = myOrg?.uuid; const { mutate: createTeamMember } = usePostV2CoreTeamLeader({ onSuccess(data) { diff --git a/src/components/elements/Inputs/DataTable/RHFFundingTypeDataTable.tsx b/src/components/elements/Inputs/DataTable/RHFFundingTypeDataTable.tsx index af2a84ba9..963f10d0c 100644 --- a/src/components/elements/Inputs/DataTable/RHFFundingTypeDataTable.tsx +++ b/src/components/elements/Inputs/DataTable/RHFFundingTypeDataTable.tsx @@ -5,9 +5,9 @@ import { useController, UseControllerProps, UseFormReturn } from "react-hook-for import * as yup from "yup"; import { FieldType, FormField } from "@/components/extensive/WizardForm/types"; -import { useMyOrg } from "@/connections/Organisation"; import { getFundingTypesOptions } from "@/constants/options/fundingTypes"; import { useDeleteV2FundingTypeUUID, usePostV2FundingType } from "@/generated/apiComponents"; +import { useMyOrg } from "@/hooks/useMyOrg"; import { formatOptionsList } from "@/utils/options"; import DataTable, { DataTableProps } from "./DataTable"; @@ -88,7 +88,8 @@ const RHFFundingTypeDataTable = ({ onChangeCapture, ...props }: PropsWithChildre const { field } = useController(props); const value = field?.value || []; - const [, { organisationId }] = useMyOrg(); + const myOrg = useMyOrg(); + const organisationId = myOrg?.uuid; const { mutate: createTeamMember } = usePostV2FundingType({ onSuccess(data) { diff --git a/src/components/elements/Inputs/DataTable/RHFLeadershipTeamTable.tsx b/src/components/elements/Inputs/DataTable/RHFLeadershipTeamTable.tsx index 273b56643..7f8739163 100644 --- a/src/components/elements/Inputs/DataTable/RHFLeadershipTeamTable.tsx +++ b/src/components/elements/Inputs/DataTable/RHFLeadershipTeamTable.tsx @@ -5,9 +5,9 @@ import { useController, UseControllerProps, UseFormReturn } from "react-hook-for import * as yup from "yup"; import { FieldType } from "@/components/extensive/WizardForm/types"; -import { useMyOrg } from "@/connections/Organisation"; import { getGenderOptions } from "@/constants/options/gender"; import { useDeleteV2LeadershipTeamUUID, usePostV2LeadershipTeam } from "@/generated/apiComponents"; +import { useMyOrg } from "@/hooks/useMyOrg"; import { formatOptionsList } from "@/utils/options"; import DataTable, { DataTableProps } from "./DataTable"; @@ -42,7 +42,8 @@ const RHFLeadershipTeamDataTable = ({ onChangeCapture, ...props }: PropsWithChil const { field } = useController(props); const value = field?.value || []; - const [, { organisationId }] = useMyOrg(); + const myOrg = useMyOrg(); + const organisationId = myOrg?.uuid; const { mutate: createTeamMember } = usePostV2LeadershipTeam({ onSuccess(data) { diff --git a/src/components/elements/Inputs/DataTable/RHFOwnershipStakeTable.tsx b/src/components/elements/Inputs/DataTable/RHFOwnershipStakeTable.tsx index 866b5373a..74694f60c 100644 --- a/src/components/elements/Inputs/DataTable/RHFOwnershipStakeTable.tsx +++ b/src/components/elements/Inputs/DataTable/RHFOwnershipStakeTable.tsx @@ -5,9 +5,9 @@ import { useController, UseControllerProps, UseFormReturn } from "react-hook-for import * as yup from "yup"; import { FieldType } from "@/components/extensive/WizardForm/types"; -import { useMyOrg } from "@/connections/Organisation"; import { getGenderOptions } from "@/constants/options/gender"; import { useDeleteV2OwnershipStakeUUID, usePostV2OwnershipStake } from "@/generated/apiComponents"; +import { useMyOrg } from "@/hooks/useMyOrg"; import { formatOptionsList } from "@/utils/options"; import DataTable, { DataTableProps } from "./DataTable"; @@ -41,7 +41,8 @@ const RHFOwnershipStakeTable = ({ onChangeCapture, ...props }: PropsWithChildren const { field } = useController(props); const value = field?.value || []; - const [, { organisationId }] = useMyOrg(); + const myOrg = useMyOrg(); + const organisationId = myOrg?.uuid; const { mutate: createTeamMember } = usePostV2OwnershipStake({ onSuccess(data) { diff --git a/src/components/elements/Inputs/Dropdown/Dropdown.stories.tsx b/src/components/elements/Inputs/Dropdown/Dropdown.stories.tsx index 12577f4ea..307a6b83f 100644 --- a/src/components/elements/Inputs/Dropdown/Dropdown.stories.tsx +++ b/src/components/elements/Inputs/Dropdown/Dropdown.stories.tsx @@ -3,7 +3,6 @@ import { useState } from "react"; import { OptionValue } from "@/types/common"; import { toArray } from "@/utils/array"; -import Log from "@/utils/log"; import Component, { DropdownProps as Props } from "./Dropdown"; @@ -53,7 +52,7 @@ export const SingleSelect: Story = { {...args} value={value} onChange={v => { - Log.info("onChange", v); + console.log(v); setValue(v); }} /> diff --git a/src/components/elements/Inputs/FileInput/FilePreviewTable.tsx b/src/components/elements/Inputs/FileInput/FilePreviewTable.tsx index 37c5baa3e..56f216d6d 100644 --- a/src/components/elements/Inputs/FileInput/FilePreviewTable.tsx +++ b/src/components/elements/Inputs/FileInput/FilePreviewTable.tsx @@ -11,7 +11,6 @@ import { useLoading } from "@/context/loaderAdmin.provider"; import { useModalContext } from "@/context/modal.provider"; import { usePatchV2MediaProjectProjectMediaUuid, usePatchV2MediaUuid } from "@/generated/apiComponents"; import { UploadedFile } from "@/types/common"; -import Log from "@/utils/log"; import Menu from "../../Menu/Menu"; import Table from "../../Table/Table"; @@ -90,7 +89,7 @@ const FilePreviewTable = ({ items, onDelete, updateFile, entityData }: FilePrevi }); } } catch (error) { - Log.error("Error updating cover status:", error); + console.error("Error updating cover status:", error); } finally { hideLoader(); } @@ -105,7 +104,7 @@ const FilePreviewTable = ({ items, onDelete, updateFile, entityData }: FilePrevi }); updateFile?.({ ...item, is_public: checked }); } catch (error) { - Log.error("Error updating public status:", error); + console.error("Error updating public status:", error); } finally { hideLoader(); } diff --git a/src/components/elements/Inputs/FileInput/RHFFileInput.tsx b/src/components/elements/Inputs/FileInput/RHFFileInput.tsx index f38c1fb8c..85a98cb5a 100644 --- a/src/components/elements/Inputs/FileInput/RHFFileInput.tsx +++ b/src/components/elements/Inputs/FileInput/RHFFileInput.tsx @@ -13,7 +13,6 @@ import { getCurrentPathEntity } from "@/helpers/entity"; import { UploadedFile } from "@/types/common"; import { toArray } from "@/utils/array"; import { getErrorMessages } from "@/utils/errors"; -import Log from "@/utils/log"; import FileInput, { FileInputProps } from "./FileInput"; import { VARIANT_FILE_INPUT_MODAL_ADD_IMAGES_WITH_MAP } from "./FileInputVariants"; @@ -185,7 +184,7 @@ const RHFFileInput = ({ body.append("lng", location.longitude.toString()); } } catch (e) { - Log.error("Failed to append geotagging information", e); + console.log(e); } upload?.({ diff --git a/src/components/elements/Inputs/Select/Select.stories.tsx b/src/components/elements/Inputs/Select/Select.stories.tsx index 913f14bae..f9c650963 100644 --- a/src/components/elements/Inputs/Select/Select.stories.tsx +++ b/src/components/elements/Inputs/Select/Select.stories.tsx @@ -1,7 +1,5 @@ import { Meta, StoryObj } from "@storybook/react"; -import Log from "@/utils/log"; - import Component from "./Select"; const meta: Meta = { @@ -17,7 +15,7 @@ export const Default: Story = { label: "Select label", description: "Select description", placeholder: "placeholder", - onChange: Log.info, + onChange: console.log, options: [ { title: "Option 1", diff --git a/src/components/elements/Inputs/SelectImage/SelectImage.stories.tsx b/src/components/elements/Inputs/SelectImage/SelectImage.stories.tsx index 4aa4fc103..5cd067b2c 100644 --- a/src/components/elements/Inputs/SelectImage/SelectImage.stories.tsx +++ b/src/components/elements/Inputs/SelectImage/SelectImage.stories.tsx @@ -1,7 +1,5 @@ import { Meta, StoryObj } from "@storybook/react"; -import Log from "@/utils/log"; - import Component from "./SelectImage"; const meta: Meta = { @@ -17,7 +15,7 @@ export const Default: Story = { label: "Select Image label", description: "Select Image description", placeholder: "placeholder", - onChange: Log.info, + onChange: console.log, options: [ { title: "Option 1", diff --git a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.stories.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.stories.tsx index 6e0df7760..56d46691d 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.stories.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.stories.tsx @@ -1,8 +1,6 @@ import { Meta, StoryObj } from "@storybook/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import Log from "@/utils/log"; - import Components from "./TreeSpeciesInput"; const meta: Meta = { @@ -34,8 +32,8 @@ export const Default: Story = { amount: 23 } ], - onChange: value => Log.info("onChange", value), - clearErrors: () => Log.info("clearErrors") + onChange: value => console.log("onChange", value), + clearErrors: () => console.log("clearErrors") } }; diff --git a/src/components/elements/Map-mapbox/Map.stories.tsx b/src/components/elements/Map-mapbox/Map.stories.tsx index ea8d2a0ce..16a5e739c 100644 --- a/src/components/elements/Map-mapbox/Map.stories.tsx +++ b/src/components/elements/Map-mapbox/Map.stories.tsx @@ -6,7 +6,6 @@ import Toast from "@/components/elements/Toast/Toast"; import ModalRoot from "@/components/extensive/Modal/ModalRoot"; import ModalProvider from "@/context/modal.provider"; import ToastProvider from "@/context/toast.provider"; -import Log from "@/utils/log"; import Component from "./Map"; import sample from "./sample.json"; @@ -35,8 +34,8 @@ export const Default: Story = { ) ], args: { - onGeojsonChange: Log.info, - onError: errors => Log.info(JSON.stringify(errors)) + onGeojsonChange: console.log, + onError: errors => console.log(JSON.stringify(errors)) } }; diff --git a/src/components/elements/Map-mapbox/Map.tsx b/src/components/elements/Map-mapbox/Map.tsx index c8606fe4f..9fba75b48 100644 --- a/src/components/elements/Map-mapbox/Map.tsx +++ b/src/components/elements/Map-mapbox/Map.tsx @@ -4,7 +4,8 @@ import { useT } from "@transifex/react"; import _ from "lodash"; import mapboxgl, { LngLat } from "mapbox-gl"; import { useRouter } from "next/router"; -import React, { DetailedHTMLProps, HTMLAttributes, useEffect, useState } from "react"; +import React, { useEffect } from "react"; +import { DetailedHTMLProps, HTMLAttributes, useState } from "react"; import { When } from "react-if"; import { twMerge } from "tailwind-merge"; import { ValidationError } from "yup"; @@ -33,7 +34,6 @@ import { usePutV2TerrafundPolygonUuid } from "@/generated/apiComponents"; import { DashboardGetProjectsData, SitePolygonsDataResponse } from "@/generated/apiSchemas"; -import Log from "@/utils/log"; import { ImageGalleryItemData } from "../ImageGallery/ImageGalleryItem"; import { AdminPopup } from "./components/AdminPopup"; @@ -322,7 +322,7 @@ export const MapContainer = ({ }); if (!response) { - Log.error("No response received from the server."); + console.error("No response received from the server."); openNotification("error", t("Error!"), t("No response received from the server.")); return; } @@ -341,7 +341,7 @@ export const MapContainer = ({ hideLoader(); openNotification("success", t("Success!"), t("Image downloaded successfully")); } catch (error) { - Log.error("Download error:", error); + console.error("Download error:", error); hideLoader(); } }; diff --git a/src/components/elements/Map-mapbox/MapControls/CheckIndividualPolygonControl.tsx b/src/components/elements/Map-mapbox/MapControls/CheckIndividualPolygonControl.tsx index b60108a7c..ce10f1775 100644 --- a/src/components/elements/Map-mapbox/MapControls/CheckIndividualPolygonControl.tsx +++ b/src/components/elements/Map-mapbox/MapControls/CheckIndividualPolygonControl.tsx @@ -11,7 +11,6 @@ import { usePostV2TerrafundValidationPolygon } from "@/generated/apiComponents"; import { ClippedPolygonResponse, SitePolygonsDataResponse } from "@/generated/apiSchemas"; -import Log from "@/utils/log"; import Button from "../../Button/Button"; @@ -78,7 +77,7 @@ const CheckIndividualPolygonControl = ({ viewRequestSuport }: { viewRequestSupor hideLoader(); }, onError: error => { - Log.error("Error clipping polygons:", error); + console.error("Error clipping polygons:", error); openNotification("error", t("Error! Could not fix polygons"), t("Please try again later.")); } }); diff --git a/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx b/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx index 9c85f574c..fcfc4ea3a 100644 --- a/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx +++ b/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx @@ -22,7 +22,6 @@ import { usePostV2TerrafundValidationSitePolygons } from "@/generated/apiComponents"; import { ClippedPolygonResponse, SitePolygon } from "@/generated/apiSchemas"; -import Log from "@/utils/log"; import Button from "../../Button/Button"; import Text from "../../Text/Text"; @@ -121,7 +120,7 @@ const CheckPolygonControl = (props: CheckSitePolygonProps) => { closeModal(ModalId.FIX_POLYGONS); }, onError: error => { - Log.error("Error clipping polygons:", error); + console.error("Error clipping polygons:", error); displayNotification(t("An error occurred while fixing polygons. Please try again."), "error", t("Error")); } }); diff --git a/src/components/elements/Map-mapbox/MapLayers/GeoJsonLayer.tsx b/src/components/elements/Map-mapbox/MapLayers/GeoJsonLayer.tsx index 8dab3f574..c6f5872d9 100644 --- a/src/components/elements/Map-mapbox/MapLayers/GeoJsonLayer.tsx +++ b/src/components/elements/Map-mapbox/MapLayers/GeoJsonLayer.tsx @@ -3,7 +3,6 @@ import { LngLatBoundsLike } from "mapbox-gl"; import { useEffect } from "react"; import { useMapContext } from "@/context/map.provider"; -import Log from "@/utils/log"; interface GeoJSONLayerProps { geojson: any; @@ -19,7 +18,7 @@ export const GeoJSONLayer = ({ geojson }: GeoJSONLayerProps) => { draw?.set(geojson); map.fitBounds(bbox(geojson) as LngLatBoundsLike, { padding: 50, animate: false }); } catch (e) { - Log.error("invalid geoJSON", e); + console.log("invalid geoJSON", e); } }, [draw, geojson, map]); diff --git a/src/components/elements/Map-mapbox/components/DashboardPopup.tsx b/src/components/elements/Map-mapbox/components/DashboardPopup.tsx index 4ef481a3b..138cafd9d 100644 --- a/src/components/elements/Map-mapbox/components/DashboardPopup.tsx +++ b/src/components/elements/Map-mapbox/components/DashboardPopup.tsx @@ -9,7 +9,6 @@ import { } from "@/generated/apiComponents"; import TooltipGridMap from "@/pages/dashboard/components/TooltipGridMap"; import { createQueryParams } from "@/utils/dashboardUtils"; -import Log from "@/utils/log"; const client = new QueryClient(); @@ -64,7 +63,7 @@ export const DashboardPopup = (event: any) => { setItems(parsedItems); addPopupToMap(); } else { - Log.error("No data returned from the API"); + console.error("No data returned from the API"); } } diff --git a/src/components/elements/Map-mapbox/utils.ts b/src/components/elements/Map-mapbox/utils.ts index 07e76e564..7cb40b89e 100644 --- a/src/components/elements/Map-mapbox/utils.ts +++ b/src/components/elements/Map-mapbox/utils.ts @@ -17,7 +17,6 @@ import { useGetV2TerrafundPolygonBboxUuid } from "@/generated/apiComponents"; import { DashboardGetProjectsData, SitePolygon, SitePolygonsDataResponse } from "@/generated/apiSchemas"; -import Log from "@/utils/log"; import { MediaPopup } from "./components/MediaPopup"; import { BBox, Feature, FeatureCollection, GeoJsonProperties, Geometry } from "./GeoJSON"; @@ -110,7 +109,7 @@ const showPolygons = ( styles.forEach((style: LayerWithStyle, index: number) => { const layerName = `${name}-${index}`; if (!map.getLayer(layerName)) { - Log.warn(`Layer ${layerName} does not exist.`); + console.warn(`Layer ${layerName} does not exist.`); return; } const polygonStatus = style?.metadata?.polygonStatus; @@ -154,7 +153,7 @@ const handleLayerClick = ( const feature = features?.[0]; if (!feature) { - Log.warn("No feature found in click event"); + console.warn("No feature found in click event"); return; } diff --git a/src/components/elements/MapPolygonPanel/AttributeInformation.tsx b/src/components/elements/MapPolygonPanel/AttributeInformation.tsx index 05f893ce2..b914412b7 100644 --- a/src/components/elements/MapPolygonPanel/AttributeInformation.tsx +++ b/src/components/elements/MapPolygonPanel/AttributeInformation.tsx @@ -8,7 +8,6 @@ import { useMapAreaContext } from "@/context/mapArea.provider"; import { useNotificationContext } from "@/context/notification.provider"; import { useGetV2TerrafundPolygonUuid, usePutV2TerrafundSitePolygonUuid } from "@/generated/apiComponents"; import { SitePolygon } from "@/generated/apiSchemas"; -import Log from "@/utils/log"; import Text from "../Text/Text"; import { useTranslatedOptions } from "./hooks/useTranslatedOptions"; @@ -176,7 +175,7 @@ const AttributeInformation = ({ handleClose }: { handleClose: () => void }) => { } ); } catch (error) { - Log.error("Error updating polygon data:", error); + console.error("Error updating polygon data:", error); } } }; diff --git a/src/components/elements/MapPolygonPanel/ChecklistInformation.tsx b/src/components/elements/MapPolygonPanel/ChecklistInformation.tsx index b04ca404d..60d6277b1 100644 --- a/src/components/elements/MapPolygonPanel/ChecklistInformation.tsx +++ b/src/components/elements/MapPolygonPanel/ChecklistInformation.tsx @@ -8,7 +8,6 @@ import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import { V2TerrafundCriteriaData } from "@/generated/apiSchemas"; import { isCompletedDataOrEstimatedArea } from "@/helpers/polygonValidation"; import { useMessageValidators } from "@/hooks/useMessageValidations"; -import Log from "@/utils/log"; import Text from "../Text/Text"; @@ -25,7 +24,7 @@ export const validationLabels: any = { }; function useRenderCounter() { const ref = useRef(0); - Log.debug(`Render count: ${++ref.current}`); + console.log(`Render count: ${++ref.current}`); } const ChecklistInformation = ({ criteriaData }: { criteriaData: V2TerrafundCriteriaData }) => { useRenderCounter(); diff --git a/src/components/elements/MapPolygonPanel/MapPolygonPanel.stories.tsx b/src/components/elements/MapPolygonPanel/MapPolygonPanel.stories.tsx index 517286631..aaff2349f 100644 --- a/src/components/elements/MapPolygonPanel/MapPolygonPanel.stories.tsx +++ b/src/components/elements/MapPolygonPanel/MapPolygonPanel.stories.tsx @@ -1,8 +1,6 @@ import { Meta, StoryObj } from "@storybook/react"; import { useState } from "react"; -import Log from "@/utils/log"; - import Component from "./MapPolygonPanel"; const meta: Meta = { @@ -97,7 +95,7 @@ export const Default: Story = { }, args: { title: "Project Sites", - onSelectItem: Log.info + onSelectItem: console.log } }; @@ -115,6 +113,6 @@ export const OpenPolygonCheck: Story = { }, args: { title: "Project Sites", - onSelectItem: Log.info + onSelectItem: console.log } }; diff --git a/src/components/elements/MapSidePanel/MapSidePanel.stories.tsx b/src/components/elements/MapSidePanel/MapSidePanel.stories.tsx index 1cac4cadf..843f91075 100644 --- a/src/components/elements/MapSidePanel/MapSidePanel.stories.tsx +++ b/src/components/elements/MapSidePanel/MapSidePanel.stories.tsx @@ -1,8 +1,6 @@ import { Meta, StoryObj } from "@storybook/react"; import { useState } from "react"; -import Log from "@/utils/log"; - import Component from "./MapSidePanel"; const meta: Meta = { @@ -37,7 +35,7 @@ const items = [ title: "Puerto Princesa Subterranean River National Park Forest Corridor", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: Log.info, + setClickedButton: console.log, onCheckboxChange: () => {}, refContainer: null, type: "sites", @@ -48,7 +46,7 @@ const items = [ title: "A medium sized project site to see how it looks with 2 lines", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: Log.info, + setClickedButton: console.log, onCheckboxChange: () => {}, refContainer: null, type: "sites", @@ -59,7 +57,7 @@ const items = [ title: "A shorter project site", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: Log.info, + setClickedButton: console.log, onCheckboxChange: () => {}, refContainer: null, type: "sites", @@ -71,7 +69,7 @@ const items = [ "Very long name A medium sized project site to see how it looks with 2 lines A medium sized project site to see how it looks with 2 lines A medium sized project site to see how it looks with 2 lines", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: Log.info, + setClickedButton: console.log, onCheckboxChange: () => {}, refContainer: null, type: "sites", @@ -82,7 +80,7 @@ const items = [ title: "A shorter project site", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: Log.info, + setClickedButton: console.log, onCheckboxChange: () => {}, refContainer: null, type: "sites", @@ -93,7 +91,7 @@ const items = [ title: "A shorter project site", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: Log.info, + setClickedButton: console.log, onCheckboxChange: () => {}, refContainer: null, type: "sites", diff --git a/src/components/extensive/EntityMapAndGalleryCard/EntityMapAndGalleryCard.tsx b/src/components/extensive/EntityMapAndGalleryCard/EntityMapAndGalleryCard.tsx index 2d9377f5f..d8bd440b5 100644 --- a/src/components/extensive/EntityMapAndGalleryCard/EntityMapAndGalleryCard.tsx +++ b/src/components/extensive/EntityMapAndGalleryCard/EntityMapAndGalleryCard.tsx @@ -26,7 +26,6 @@ import { import { getCurrentPathEntity } from "@/helpers/entity"; import { useGetImagesGeoJSON } from "@/hooks/useImageGeoJSON"; import { EntityName, FileType } from "@/types/common"; -import Log from "@/utils/log"; import ModalAddImages from "../Modal/ModalAddImages"; import { ModalId } from "../Modal/ModalConst"; @@ -167,7 +166,7 @@ const EntityMapAndGalleryCard = ({ collection="media" entityData={entityData} setErrorMessage={message => { - Log.error(message); + console.error(message); }} /> ); diff --git a/src/components/extensive/Modal/FormModal.stories.tsx b/src/components/extensive/Modal/FormModal.stories.tsx index b3538b6fe..a3379004c 100644 --- a/src/components/extensive/Modal/FormModal.stories.tsx +++ b/src/components/extensive/Modal/FormModal.stories.tsx @@ -2,7 +2,6 @@ import { Meta, StoryObj } from "@storybook/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { getDisturbanceTableFields } from "@/components/elements/Inputs/DataTable/RHFDisturbanceTable"; -import Log from "@/utils/log"; import Component, { FormModalProps as Props } from "./FormModal"; @@ -26,6 +25,6 @@ export const Default: Story = { args: { title: "Add new disturbance", fields: getDisturbanceTableFields({ hasIntensity: true, hasExtent: true }), - onSubmit: Log.info + onSubmit: console.log } }; diff --git a/src/components/extensive/Modal/Modal.stories.tsx b/src/components/extensive/Modal/Modal.stories.tsx index e6056db8d..70bc34abd 100644 --- a/src/components/extensive/Modal/Modal.stories.tsx +++ b/src/components/extensive/Modal/Modal.stories.tsx @@ -1,7 +1,5 @@ import { Meta, StoryObj } from "@storybook/react"; -import Log from "@/utils/log"; - import { IconNames } from "../Icon/Icon"; import Component, { ModalProps as Props } from "./Modal"; @@ -29,11 +27,11 @@ export const Default: Story = { }, primaryButtonProps: { children: "Close and continue later", - onClick: () => Log.info("close clicked") + onClick: console.log }, secondaryButtonProps: { children: "Cancel", - onClick: () => Log.info("secondary clicked") + onClick: console.log } } }; diff --git a/src/components/extensive/Modal/ModalAddImages.tsx b/src/components/extensive/Modal/ModalAddImages.tsx index 10aef1f13..62b7a39f5 100644 --- a/src/components/extensive/Modal/ModalAddImages.tsx +++ b/src/components/extensive/Modal/ModalAddImages.tsx @@ -15,7 +15,6 @@ import Status from "@/components/elements/Status/Status"; import Text from "@/components/elements/Text/Text"; import { useDeleteV2FilesUUID, usePostV2FileUploadMODELCOLLECTIONUUID } from "@/generated/apiComponents"; import { FileType, UploadedFile } from "@/types/common"; -import Log from "@/utils/log"; import Icon, { IconNames } from "../Icon/Icon"; import { ModalProps } from "./Modal"; @@ -212,7 +211,7 @@ const ModalAddImages: FC = ({ body.append("lng", location.longitude.toString()); } } catch (e) { - Log.error(e); + console.log(e); } uploadFile?.({ diff --git a/src/components/extensive/Modal/ModalImageDetails.tsx b/src/components/extensive/Modal/ModalImageDetails.tsx index 9f9f12065..346c4fea4 100644 --- a/src/components/extensive/Modal/ModalImageDetails.tsx +++ b/src/components/extensive/Modal/ModalImageDetails.tsx @@ -14,7 +14,6 @@ import Modal from "@/components/extensive/Modal/Modal"; import { useModalContext } from "@/context/modal.provider"; import { useNotificationContext } from "@/context/notification.provider"; import { usePatchV2MediaProjectProjectMediaUuid, usePatchV2MediaUuid } from "@/generated/apiComponents"; -import Log from "@/utils/log"; import Icon, { IconNames } from "../Icon/Icon"; import PageBreadcrumbs from "../PageElements/Breadcrumbs/PageBreadcrumbs"; @@ -137,7 +136,7 @@ const ModalImageDetails: FC = ({ onClose?.(); } catch (error) { openNotification("error", t("Error"), t("Failed to update image details")); - Log.error("Failed to update image details:", error); + console.error("Failed to update image details:", error); } }; diff --git a/src/components/extensive/Modal/ModalWithClose.stories.tsx b/src/components/extensive/Modal/ModalWithClose.stories.tsx index f93f542c4..d8fed45e4 100644 --- a/src/components/extensive/Modal/ModalWithClose.stories.tsx +++ b/src/components/extensive/Modal/ModalWithClose.stories.tsx @@ -1,7 +1,5 @@ import { Meta, StoryObj } from "@storybook/react"; -import Log from "@/utils/log"; - import { IconNames } from "../Icon/Icon"; import { ModalProps as Props } from "./Modal"; import Component from "./ModalWithClose"; @@ -30,11 +28,11 @@ export const Default: Story = { }, primaryButtonProps: { children: "Close and continue later", - onClick: () => Log.info("close clicked") + onClick: console.log }, secondaryButtonProps: { children: "Cancel", - onClick: () => Log.info("secondary clicked") + onClick: console.log } } }; diff --git a/src/components/extensive/Modal/ModalWithLogo.stories.tsx b/src/components/extensive/Modal/ModalWithLogo.stories.tsx index 127845067..a55823498 100644 --- a/src/components/extensive/Modal/ModalWithLogo.stories.tsx +++ b/src/components/extensive/Modal/ModalWithLogo.stories.tsx @@ -1,8 +1,6 @@ import { Meta, StoryObj } from "@storybook/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import Log from "@/utils/log"; - import { IconNames } from "../Icon/Icon"; import Component, { ModalWithLogoProps as Props } from "./ModalWithLogo"; @@ -34,11 +32,11 @@ export const Default: Story = { }, primaryButtonProps: { children: "Close and continue later", - onClick: () => Log.info("close clicked") + onClick: console.log }, secondaryButtonProps: { children: "Cancel", - onClick: () => Log.info("secondary clicked") + onClick: console.log } } }; diff --git a/src/components/extensive/Modal/ModalWithLogo.tsx b/src/components/extensive/Modal/ModalWithLogo.tsx index abe0e6d37..c3d70d4cf 100644 --- a/src/components/extensive/Modal/ModalWithLogo.tsx +++ b/src/components/extensive/Modal/ModalWithLogo.tsx @@ -14,8 +14,12 @@ import { formatCommentaryDate } from "@/components/elements/Map-mapbox/utils"; import StepProgressbar from "@/components/elements/ProgressBar/StepProgressbar/StepProgressbar"; import { StatusEnum } from "@/components/elements/Status/constants/statusMap"; import Text from "@/components/elements/Text/Text"; -import { useMyUser } from "@/connections/User"; -import { GetV2AuditStatusENTITYUUIDResponse, useGetV2AuditStatusENTITYUUID } from "@/generated/apiComponents"; +import { + GetAuthMeResponse, + GetV2AuditStatusENTITYUUIDResponse, + useGetAuthMe, + useGetV2AuditStatusENTITYUUID +} from "@/generated/apiComponents"; import { statusActionsMap } from "@/hooks/AuditStatus/useAuditLogActions"; import Icon, { IconNames } from "../Icon/Icon"; @@ -56,7 +60,7 @@ const ModalWithLogo: FC = ({ } }); - const [, { user }] = useMyUser(); + const { data: authMe } = useGetAuthMe<{ data: GetAuthMeResponse }>({}); const [commentsAuditLogData, restAuditLogData] = useMemo(() => { const commentsAuditLog: GetV2AuditStatusENTITYUUIDResponse = []; @@ -120,8 +124,8 @@ const ModalWithLogo: FC = ({
= { @@ -20,5 +18,5 @@ export const Default: Story = {
) ], - args: { options: [5, 10, 15, 20, 50], onChange: Log.info, defaultValue: 5 } + args: { options: [5, 10, 15, 20, 50], onChange: console.log, defaultValue: 5 } }; diff --git a/src/components/extensive/WelcomeTour/WelcomeTour.tsx b/src/components/extensive/WelcomeTour/WelcomeTour.tsx index bf192837a..b85277e2a 100644 --- a/src/components/extensive/WelcomeTour/WelcomeTour.tsx +++ b/src/components/extensive/WelcomeTour/WelcomeTour.tsx @@ -4,9 +4,9 @@ import { FC, useCallback, useEffect, useMemo, useState } from "react"; import { When } from "react-if"; import Joyride, { Step } from "react-joyride"; -import { useMyUser } from "@/connections/User"; import { useModalContext } from "@/context/modal.provider"; import { useNavbarContext } from "@/context/navbar.provider"; +import { useUserData } from "@/hooks/useUserData"; import { ModalId } from "../Modal/ModalConst"; import ToolTip from "./Tooltip"; @@ -31,9 +31,9 @@ const WelcomeTour: FC = ({ tourId, tourSteps, onFinish, onStart, onDontS const { setIsOpen: setIsNavOpen, setLinksDisabled: setNavLinksDisabled } = useNavbarContext(); const isLg = useMediaQuery("(min-width:1024px)"); - const [, { user }] = useMyUser(); + const userData = useUserData(); - const TOUR_COMPLETED_STORAGE_KEY = `${tourId}_${TOUR_COMPLETED_KEY}_${user?.uuid}`; + const TOUR_COMPLETED_STORAGE_KEY = `${tourId}_${TOUR_COMPLETED_KEY}_${userData?.uuid}`; const TOUR_SKIPPED_STORAGE_KEY = `${tourId}_${TOUR_SKIPPED_KEY}`; const floaterProps = useMemo(() => { @@ -79,16 +79,16 @@ const WelcomeTour: FC = ({ tourId, tourSteps, onFinish, onStart, onDontS }, [closeModal, isLg, setIsNavOpen, setNavLinksDisabled]); const handleDontShowAgain = useCallback(() => { - if (user?.uuid) { + if (userData?.uuid) { localStorage.setItem(TOUR_COMPLETED_STORAGE_KEY, "true"); onDontShowAgain?.(); setModalInteracted(true); closeModal(ModalId.WELCOME_MODAL); } - }, [TOUR_COMPLETED_STORAGE_KEY, closeModal, onDontShowAgain, user?.uuid]); + }, [TOUR_COMPLETED_STORAGE_KEY, closeModal, onDontShowAgain, userData?.uuid]); useEffect(() => { - const userId = user?.uuid?.toString(); + const userId = userData?.uuid?.toString(); if (userId) { const isSkipped = sessionStorage.getItem(TOUR_SKIPPED_STORAGE_KEY) === "true"; const isCompleted = localStorage.getItem(TOUR_COMPLETED_STORAGE_KEY) === "true"; @@ -105,7 +105,7 @@ const WelcomeTour: FC = ({ tourId, tourSteps, onFinish, onStart, onDontS } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hasWelcomeModal, modalInteracted, user?.uuid]); + }, [hasWelcomeModal, modalInteracted, userData?.uuid]); useEffect(() => { if (tourEnabled) { @@ -134,7 +134,7 @@ const WelcomeTour: FC = ({ tourId, tourSteps, onFinish, onStart, onDontS } }} callback={data => { - if (data.status === "finished" && user?.uuid) { + if (data.status === "finished" && userData?.uuid) { localStorage.setItem(TOUR_COMPLETED_STORAGE_KEY, "true"); setTourEnabled(false); setNavLinksDisabled?.(false); diff --git a/src/components/extensive/WizardForm/WizardForm.stories.tsx b/src/components/extensive/WizardForm/WizardForm.stories.tsx index d351a3f2b..ac87c9ea5 100644 --- a/src/components/extensive/WizardForm/WizardForm.stories.tsx +++ b/src/components/extensive/WizardForm/WizardForm.stories.tsx @@ -10,7 +10,6 @@ import { } from "@/components/elements/Inputs/DataTable/RHFFundingTypeDataTable"; import { getCountriesOptions } from "@/constants/options/countries"; import { FileType } from "@/types/common"; -import Log from "@/utils/log"; import Component, { WizardFormProps as Props } from "."; import { FieldType, FormStepSchema } from "./types"; @@ -309,8 +308,8 @@ export const CreateForm: Story = { ), args: { steps: getSteps(false), - onStepChange: Log.info, - onChange: Log.info, + onStepChange: console.log, + onChange: console.log, nextButtonText: "Save and Continue", submitButtonText: "Submit", hideBackButton: false, @@ -327,8 +326,8 @@ export const EditForm = { ...CreateForm, args: { steps: getSteps(true), - onStepChange: Log.info, - onChange: Log.info, + onStepChange: console.log, + onChange: console.log, nextButtonText: "Save", submitButtonText: "Save", hideBackButton: true, diff --git a/src/components/extensive/WizardForm/index.tsx b/src/components/extensive/WizardForm/index.tsx index 4cea6fcb1..887c5a9f3 100644 --- a/src/components/extensive/WizardForm/index.tsx +++ b/src/components/extensive/WizardForm/index.tsx @@ -12,7 +12,6 @@ import { FormStepSchema } from "@/components/extensive/WizardForm/types"; import { useModalContext } from "@/context/modal.provider"; import { ErrorWrapper } from "@/generated/apiFetcher"; import { useDebounce } from "@/hooks/useDebounce"; -import Log from "@/utils/log"; import { ModalId } from "../Modal/ModalConst"; import { FormFooter } from "./FormFooter"; @@ -89,9 +88,11 @@ function WizardForm(props: WizardFormProps) { const formHasError = Object.values(formHook.formState.errors || {}).filter(item => !!item).length > 0; - Log.debug("Form Steps", props.steps); - Log.debug("Form Values", formHook.watch()); - Log.debug("Form Errors", formHook.formState.errors); + if (process.env.NODE_ENV === "development") { + console.debug("Form Steps", props.steps); + console.debug("Form Values", formHook.watch()); + console.debug("Form Errors", formHook.formState.errors); + } const onChange = useDebounce(() => !formHasError && props.onChange?.(formHook.getValues())); diff --git a/src/components/generic/Layout/MainLayout.tsx b/src/components/generic/Layout/MainLayout.tsx index 8f289427c..9bc770e3e 100644 --- a/src/components/generic/Layout/MainLayout.tsx +++ b/src/components/generic/Layout/MainLayout.tsx @@ -2,12 +2,14 @@ import { DetailedHTMLProps, HTMLAttributes, PropsWithChildren } from "react"; import Navbar from "@/components/generic/Navbar/Navbar"; -interface MainLayoutProps extends DetailedHTMLProps, HTMLDivElement> {} +interface MainLayoutProps extends DetailedHTMLProps, HTMLDivElement> { + isLoggedIn?: boolean; +} const MainLayout = (props: PropsWithChildren) => { return (
- +
{props.children}
); diff --git a/src/components/generic/Navbar/Navbar.stories.tsx b/src/components/generic/Navbar/Navbar.stories.tsx index 148b60ef1..8a6659507 100644 --- a/src/components/generic/Navbar/Navbar.stories.tsx +++ b/src/components/generic/Navbar/Navbar.stories.tsx @@ -1,8 +1,6 @@ import { Meta, StoryObj } from "@storybook/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { buildStore } from "@/utils/testStore"; - import Component from "./Navbar"; const meta: Meta = { @@ -15,24 +13,21 @@ type Story = StoryObj; const client = new QueryClient(); export const LoggedIn: Story = { - parameters: { - storeBuilder: buildStore().addLogin("fakeauthtoken") - }, decorators: [ Story => ( ) - ] + ], + args: { + isLoggedIn: true + } }; export const LoggedOut: Story = { - decorators: [ - Story => ( - - - - ) - ] + ...LoggedIn, + args: { + isLoggedIn: false + } }; diff --git a/src/components/generic/Navbar/Navbar.tsx b/src/components/generic/Navbar/Navbar.tsx index 229be519f..6c39f2a27 100644 --- a/src/components/generic/Navbar/Navbar.tsx +++ b/src/components/generic/Navbar/Navbar.tsx @@ -10,7 +10,11 @@ import { useNavbarContext } from "@/context/navbar.provider"; import Container from "../Layout/Container"; import NavbarContent from "./NavbarContent"; -const Navbar = (): JSX.Element => { +export interface NavbarProps { + isLoggedIn?: boolean; +} + +const Navbar = (props: NavbarProps): JSX.Element => { const { isOpen, setIsOpen, linksDisabled } = useNavbarContext(); const isLg = useMediaQuery("(min-width:1024px)"); @@ -33,7 +37,7 @@ const Navbar = (): JSX.Element => { - + @@ -64,6 +68,7 @@ const Navbar = (): JSX.Element => { "relative flex flex-col items-center justify-center gap-4 sm:hidden", isOpen && "h-[calc(100vh-70px)]" )} + isLoggedIn={props.isLoggedIn} handleClose={() => setIsOpen?.(false)} />
diff --git a/src/components/generic/Navbar/NavbarContent.tsx b/src/components/generic/Navbar/NavbarContent.tsx index 6b97ccdb4..7372fa387 100644 --- a/src/components/generic/Navbar/NavbarContent.tsx +++ b/src/components/generic/Navbar/NavbarContent.tsx @@ -7,24 +7,23 @@ import { Else, If, Then, When } from "react-if"; import LanguagesDropdown from "@/components/elements/Inputs/LanguageDropdown/LanguagesDropdown"; import { IconNames } from "@/components/extensive/Icon/Icon"; import List from "@/components/extensive/List/List"; -import { useLogin } from "@/connections/Login"; -import { useMyOrg } from "@/connections/Organisation"; import { useNavbarContext } from "@/context/navbar.provider"; import { useLogout } from "@/hooks/logout"; +import { useMyOrg } from "@/hooks/useMyOrg"; import { OptionValue } from "@/types/common"; import NavbarItem from "./NavbarItem"; import { getNavbarItems } from "./navbarItems"; interface NavbarContentProps extends DetailedHTMLProps, HTMLDivElement> { + isLoggedIn?: boolean; handleClose?: () => void; } -const NavbarContent = ({ handleClose, ...rest }: NavbarContentProps) => { - const [, { isLoggedIn }] = useLogin(); +const NavbarContent = ({ isLoggedIn, handleClose, ...rest }: NavbarContentProps) => { const router = useRouter(); const t = useT(); - const [, myOrg] = useMyOrg(); + const myOrg = useMyOrg(); const logout = useLogout(); const { private: privateNavItems, public: publicNavItems } = getNavbarItems(t, myOrg); diff --git a/src/components/generic/Navbar/navbarItems.ts b/src/components/generic/Navbar/navbarItems.ts index 968f24f5f..406ddc8e2 100644 --- a/src/components/generic/Navbar/navbarItems.ts +++ b/src/components/generic/Navbar/navbarItems.ts @@ -1,8 +1,8 @@ import { useT } from "@transifex/react"; import { tourSelectors } from "@/components/extensive/WelcomeTour/useGetHomeTourItems"; -import { MyOrganisationConnection } from "@/connections/Organisation"; import { zendeskSupportLink } from "@/constants/links"; +import { V2MonitoringOrganisationRead } from "@/generated/apiSchemas"; interface INavbarItem { title: string; @@ -15,10 +15,10 @@ interface INavbarItems { private: INavbarItem[]; } -export const getNavbarItems = (t: typeof useT, myOrg?: MyOrganisationConnection): INavbarItems => { - const { userStatus, organisation } = myOrg ?? {}; - const { status } = organisation ?? {}; - const visibility = Boolean(organisation && status !== "rejected" && status !== "draft" && userStatus !== "requested"); +export const getNavbarItems = (t: typeof useT, myOrg?: V2MonitoringOrganisationRead | null): INavbarItems => { + const visibility = Boolean( + myOrg && myOrg?.status !== "rejected" && myOrg?.status !== "draft" && myOrg.users_status !== "requested" + ); return { public: [ @@ -53,7 +53,7 @@ export const getNavbarItems = (t: typeof useT, myOrg?: MyOrganisationConnection) }, { title: t("My Organization"), - url: myOrg?.organisationId ? `/organization/${myOrg?.organisationId}` : "/", + url: myOrg?.uuid ? `/organization/${myOrg?.uuid}` : "/", visibility, tourTarget: tourSelectors.ORGANIZATION }, diff --git a/src/connections/Login.ts b/src/connections/Login.ts deleted file mode 100644 index bf54ca07a..000000000 --- a/src/connections/Login.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { createSelector } from "reselect"; - -import { removeAccessToken } from "@/admin/apiProvider/utils/token"; -import { authLogin } from "@/generated/v3/userService/userServiceComponents"; -import { authLoginFetchFailed, authLoginIsFetching } from "@/generated/v3/userService/userServicePredicates"; -import ApiSlice, { ApiDataStore } from "@/store/apiSlice"; -import { Connection } from "@/types/connection"; -import { connectionHook, connectionLoader, connectionSelector } from "@/utils/connectionShortcuts"; - -type LoginConnection = { - isLoggingIn: boolean; - isLoggedIn: boolean; - loginFailed: boolean; - token?: string; -}; - -export const login = (emailAddress: string, password: string) => authLogin({ body: { emailAddress, password } }); -export const logout = () => { - removeAccessToken(); - // When we log out, remove all cached API resources so that when we log in again, these resources - // are freshly fetched from the BE. - ApiSlice.clearApiCache(); - window.location.replace("/auth/login"); -}; - -export const selectFirstLogin = (store: ApiDataStore) => Object.values(store.logins)?.[0]?.attributes; - -const loginConnection: Connection = { - selector: createSelector( - [authLoginIsFetching, authLoginFetchFailed, selectFirstLogin], - (isLoggingIn, failedLogin, firstLogin) => { - return { - isLoggingIn, - isLoggedIn: firstLogin != null, - loginFailed: failedLogin != null, - token: firstLogin?.token - }; - } - ) -}; -export const useLogin = connectionHook(loginConnection); -export const loadLogin = connectionLoader(loginConnection); -export const selectLogin = connectionSelector(loginConnection); diff --git a/src/connections/Organisation.ts b/src/connections/Organisation.ts deleted file mode 100644 index 86fb42649..000000000 --- a/src/connections/Organisation.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { createSelector } from "reselect"; - -import { selectMe, useMyUser } from "@/connections/User"; -import { OrganisationDto } from "@/generated/v3/userService/userServiceSchemas"; -import { useConnection } from "@/hooks/useConnection"; -import { ApiDataStore } from "@/store/apiSlice"; -import { Connected, Connection } from "@/types/connection"; -import { selectorCache } from "@/utils/selectorCache"; - -type OrganisationConnection = { - organisation?: OrganisationDto; -}; - -type UserStatus = "approved" | "rejected" | "requested"; -export type MyOrganisationConnection = OrganisationConnection & { - organisationId?: string; - userStatus?: UserStatus; -}; - -type OrganisationConnectionProps = { - organisationId?: string; -}; - -const selectOrganisations = (store: ApiDataStore) => store.organisations; -const organisationSelector = (organisationId?: string) => (store: ApiDataStore) => - organisationId == null ? undefined : store.organisations?.[organisationId]; - -// TODO: This doesn't get a load/isLoaded until we have a v3 organisation get endpoint. For now we -// have to rely on the data that is already in the store. We might not even end up needing this -// connection, but it does illustrate nicely how to create a connection that takes props, so I'm -// leaving it in for now. Exported just to keep the linter happy since it's not currently used. -export const organisationConnection: Connection = { - selector: selectorCache( - ({ organisationId }) => organisationId ?? "", - ({ organisationId }) => - createSelector([organisationSelector(organisationId)], org => ({ - organisation: org?.attributes - })) - ) -}; - -const myOrganisationConnection: Connection = { - selector: createSelector([selectMe, selectOrganisations], (user, orgs) => { - const { id, meta } = user?.relationships?.org?.[0] ?? {}; - if (id == null) return {}; - - return { - organisationId: id, - organisation: orgs?.[id]?.attributes, - userStatus: meta?.userStatus as UserStatus - }; - }) -}; -// The "myOrganisationConnection" is only valid once the users/me response has been loaded, so -// this hook depends on the myUserConnection to fetch users/me and then loads the data it needs -// from the store. -export const useMyOrg = (): Connected => { - const [loaded] = useMyUser(); - const [, orgShape] = useConnection(myOrganisationConnection); - return loaded ? [true, orgShape] : [false, {}]; -}; diff --git a/src/connections/User.ts b/src/connections/User.ts deleted file mode 100644 index 36f65f448..000000000 --- a/src/connections/User.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { createSelector } from "reselect"; - -import { selectFirstLogin } from "@/connections/Login"; -import { usersFind, UsersFindVariables } from "@/generated/v3/userService/userServiceComponents"; -import { usersFindFetchFailed } from "@/generated/v3/userService/userServicePredicates"; -import { UserDto } from "@/generated/v3/userService/userServiceSchemas"; -import { ApiDataStore } from "@/store/apiSlice"; -import { Connection } from "@/types/connection"; -import { connectionHook, connectionLoader } from "@/utils/connectionShortcuts"; - -type UserConnection = { - user?: UserDto; - userLoadFailed: boolean; - - /** Used internally by the connection to determine if an attempt to load users/me should happen or not. */ - isLoggedIn: boolean; -}; - -const selectMeId = (store: ApiDataStore) => store.meta.meUserId; -const selectUsers = (store: ApiDataStore) => store.users; -export const selectMe = createSelector([selectMeId, selectUsers], (meId, users) => - meId == null ? undefined : users?.[meId] -); - -const FIND_ME: UsersFindVariables = { pathParams: { id: "me" } }; - -export const myUserConnection: Connection = { - load: ({ isLoggedIn, user }) => { - if (user == null && isLoggedIn) usersFind(FIND_ME); - }, - - isLoaded: ({ user, userLoadFailed, isLoggedIn }) => !isLoggedIn || userLoadFailed || user != null, - - selector: createSelector( - [selectMe, selectFirstLogin, usersFindFetchFailed(FIND_ME)], - (resource, firstLogin, userLoadFailure) => ({ - user: resource?.attributes, - userLoadFailed: userLoadFailure != null, - isLoggedIn: firstLogin?.token != null - }) - ) -}; -export const useMyUser = connectionHook(myUserConnection); -export const loadMyUser = connectionLoader(myUserConnection); diff --git a/src/constants/options/frameworks.ts b/src/constants/options/frameworks.ts index 271a9c810..31f0c4812 100644 --- a/src/constants/options/frameworks.ts +++ b/src/constants/options/frameworks.ts @@ -2,7 +2,6 @@ import { useEffect, useState } from "react"; import { GetListParams } from "react-admin"; import { reportingFrameworkDataProvider } from "@/admin/apiProvider/dataProviders/reportingFrameworkDataProvider"; -import Log from "@/utils/log"; async function getFrameworkChoices() { const params: GetListParams = { @@ -30,7 +29,7 @@ export function useFrameworkChoices() { try { setFrameworkChoices(await getFrameworkChoices()); } catch (error) { - Log.error("Error fetching framework choices", error); + console.error("Error fetching framework choices", error); } }; diff --git a/src/constants/options/userFrameworksChoices.ts b/src/constants/options/userFrameworksChoices.ts index 6c26a50d3..35a194e4a 100644 --- a/src/constants/options/userFrameworksChoices.ts +++ b/src/constants/options/userFrameworksChoices.ts @@ -1,14 +1,14 @@ import { useMemo } from "react"; -import { useMyUser } from "@/connections/User"; +import { useUserData } from "@/hooks/useUserData"; import { OptionInputType } from "@/types/common"; export const useUserFrameworkChoices = (): OptionInputType[] => { - const [, { user }] = useMyUser(); + const userData = useUserData(); - return useMemo(() => { + const frameworkChoices = useMemo(() => { return ( - user?.frameworks?.map( + userData?.frameworks?.map( f => ({ name: f.name, @@ -16,5 +16,7 @@ export const useUserFrameworkChoices = (): OptionInputType[] => { } as OptionInputType) ) ?? [] ); - }, [user]); + }, [userData]); + + return frameworkChoices; }; diff --git a/src/context/auth.provider.test.tsx b/src/context/auth.provider.test.tsx new file mode 100644 index 000000000..9ccade836 --- /dev/null +++ b/src/context/auth.provider.test.tsx @@ -0,0 +1,51 @@ +import { renderHook } from "@testing-library/react"; + +import AuthProvider, { useAuthContext } from "@/context/auth.provider"; +import * as api from "@/generated/apiComponents"; + +jest.mock("@/generated/apiComponents", () => ({ + __esModule: true, + useGetAuthMe: jest.fn(), + usePostAuthLogin: jest.fn() +})); + +jest.mock("@/generated/apiFetcher", () => ({ + __esModule: true, + apiFetch: jest.fn() +})); + +describe("Test auth.provider context", () => { + const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9"; + const userData = { + uuid: "1234-1234", + name: "3SC" + }; + + beforeEach(() => { + jest.resetAllMocks(); + //@ts-ignore + api.usePostAuthLogin.mockImplementation(() => ({ + mutateAsync: jest.fn(() => Promise.resolve({ data: { token } })), + isLoading: false, + error: null + })); + //@ts-ignore + api.useGetAuthMe.mockReturnValue({ + data: { + data: userData + } + }); + }); + + test("login method update local storage", async () => { + const { result } = renderHook(() => useAuthContext(), { + wrapper: props => {props.children} + }); + + jest.spyOn(window.localStorage.__proto__, "setItem"); + + await result.current.login({ email_address: "example@3sidedcube.com", password: "12345" }); + + expect(localStorage.setItem).toBeCalledWith("access_token", token); + }); +}); diff --git a/src/context/auth.provider.tsx b/src/context/auth.provider.tsx new file mode 100644 index 000000000..019d483ba --- /dev/null +++ b/src/context/auth.provider.tsx @@ -0,0 +1,59 @@ +import { createContext, useContext } from "react"; + +import { setAccessToken } from "@/admin/apiProvider/utils/token"; +import { usePostAuthLogin } from "@/generated/apiComponents"; +import { AuthLogIn } from "@/generated/apiSchemas"; + +interface IAuthContext { + login: (body: AuthLogIn, onError?: () => void) => Promise; + loginLoading: boolean; + token?: string; +} + +export const AuthContext = createContext({ + login: async () => {}, + loginLoading: false, + token: "" +}); + +type AuthProviderProps = { children: React.ReactNode; token?: string }; + +const AuthProvider = ({ children, token }: AuthProviderProps) => { + const { mutateAsync: authLogin, isLoading: loginLoading } = usePostAuthLogin(); + + const login = async (body: AuthLogIn, onError?: () => void) => { + return new Promise(r => { + authLogin({ + body + }) + .then(res => { + // @ts-expect-error + const token = res["data"].token; + + setAccessToken(token); + + r({ success: true }); + }) + .catch(() => { + onError?.(); + r({ success: false }); + }); + }); + }; + + return ( + + {children} + + ); +}; + +export const useAuthContext = () => useContext(AuthContext); + +export default AuthProvider; diff --git a/src/context/mapArea.provider.tsx b/src/context/mapArea.provider.tsx index 00e0da048..d39b1e31b 100644 --- a/src/context/mapArea.provider.tsx +++ b/src/context/mapArea.provider.tsx @@ -2,7 +2,6 @@ import React, { createContext, ReactNode, useContext, useState } from "react"; import { fetchGetV2DashboardViewProjectUuid } from "@/generated/apiComponents"; import { SitePolygon } from "@/generated/apiSchemas"; -import Log from "@/utils/log"; type MapAreaType = { isMonitoring: boolean; @@ -135,7 +134,7 @@ export const MapAreaProvider: React.FC<{ children: ReactNode }> = ({ children }) }); setIsMonitoring(isMonitoringPartner?.allowed ?? false); } catch (error) { - Log.error("Failed to check if monitoring partner:", error); + console.error("Failed to check if monitoring partner:", error); setIsMonitoring(false); } }; diff --git a/src/generated/apiComponents.ts b/src/generated/apiComponents.ts index d6d8b50d7..b927b6649 100644 --- a/src/generated/apiComponents.ts +++ b/src/generated/apiComponents.ts @@ -34403,151 +34403,6 @@ export const useGetV2DashboardTopTreesPlanted = ; - -export type GetV2DashboardIndicatorHectaresRestorationResponse = { - data?: { - restoration_strategies_represented?: { - /** - * Total amount for tree planting projects. - */ - ["tree-planting"]?: number; - /** - * Total amount for projects involving both tree planting and direct seeding. - */ - ["tree-planting,direct-seeding"]?: number; - /** - * Total amount for assisted natural regeneration projects. - */ - ["assisted-natural-regeneration"]?: number; - /** - * Total amount for projects involving both tree planting and assisted natural regeneration. - */ - ["tree-planting,assisted-natural-regeneration"]?: number; - /** - * Total amount for direct seeding projects. - */ - ["direct-seeding"]?: number; - /** - * Total amount for control projects. - */ - control?: number; - /** - * Total amount for projects with no specific restoration category. - */ - ["null"]?: number; - }; - target_land_use_types_represented?: { - /** - * Total amount for projects without a defined land use type. - */ - ["null"]?: number; - /** - * Total amount for projects involving natural forest. - */ - ["natural-forest"]?: number; - /** - * Total amount for agroforest projects. - */ - agroforest?: number; - /** - * Total amount for silvopasture projects. - */ - silvopasture?: number; - /** - * Total amount for woodlot or plantation projects. - */ - ["woodlot-or-plantation"]?: number; - /** - * Total amount for riparian area or wetland projects. - */ - ["riparian-area-or-wetland"]?: number; - /** - * Total amount for projects involving both agroforest and riparian area or wetland. - */ - ["agroforest,riparian-area-or-wetland"]?: number; - /** - * Total amount for projects involving both riparian area or wetland and woodlot or plantation. - */ - ["riparian-area-or-wetland,woodlot-or-plantation"]?: number; - /** - * Total amount for projects involving open natural ecosystem or grasslands. - */ - ["Open natural ecosystem or Grasslands"]?: number; - /** - * Total amount for urban forest projects. - */ - ["urban-forest"]?: number; - }; - }; -}; - -export type GetV2DashboardIndicatorHectaresRestorationVariables = { - queryParams?: GetV2DashboardIndicatorHectaresRestorationQueryParams; -} & ApiContext["fetcherOptions"]; - -/** - * This endpoint returns hectares restored using data from indicators 5 (restoration strategies) and 6 (target land use types). - */ -export const fetchGetV2DashboardIndicatorHectaresRestoration = ( - variables: GetV2DashboardIndicatorHectaresRestorationVariables, - signal?: AbortSignal -) => - apiFetch< - GetV2DashboardIndicatorHectaresRestorationResponse, - GetV2DashboardIndicatorHectaresRestorationError, - undefined, - {}, - GetV2DashboardIndicatorHectaresRestorationQueryParams, - {} - >({ url: "/v2/dashboard/indicator/hectares-restoration", method: "get", ...variables, signal }); - -/** - * This endpoint returns hectares restored using data from indicators 5 (restoration strategies) and 6 (target land use types). - */ -export const useGetV2DashboardIndicatorHectaresRestoration = < - TData = GetV2DashboardIndicatorHectaresRestorationResponse ->( - variables: GetV2DashboardIndicatorHectaresRestorationVariables, - options?: Omit< - reactQuery.UseQueryOptions< - GetV2DashboardIndicatorHectaresRestorationResponse, - GetV2DashboardIndicatorHectaresRestorationError, - TData - >, - "queryKey" | "queryFn" - > -) => { - const { fetcherOptions, queryOptions, queryKeyFn } = useApiContext(options); - return reactQuery.useQuery< - GetV2DashboardIndicatorHectaresRestorationResponse, - GetV2DashboardIndicatorHectaresRestorationError, - TData - >( - queryKeyFn({ - path: "/v2/dashboard/indicator/hectares-restoration", - operationId: "getV2DashboardIndicatorHectaresRestoration", - variables - }), - ({ signal }) => fetchGetV2DashboardIndicatorHectaresRestoration({ ...fetcherOptions, ...variables }, signal), - { - ...options, - ...queryOptions - } - ); -}; - export type GetV2ProjectPipelineQueryParams = { /** * Optional. Filter counts and metrics by country. @@ -36679,11 +36534,6 @@ export type QueryOperation = operationId: "getV2DashboardTopTreesPlanted"; variables: GetV2DashboardTopTreesPlantedVariables; } - | { - path: "/v2/dashboard/indicator/hectares-restoration"; - operationId: "getV2DashboardIndicatorHectaresRestoration"; - variables: GetV2DashboardIndicatorHectaresRestorationVariables; - } | { path: "/v2/project-pipeline"; operationId: "getV2ProjectPipeline"; diff --git a/src/generated/apiContext.ts b/src/generated/apiContext.ts index 5d6b74081..0a2bac201 100644 --- a/src/generated/apiContext.ts +++ b/src/generated/apiContext.ts @@ -1,7 +1,8 @@ import type { QueryKey, UseQueryOptions } from "@tanstack/react-query"; +import { useAuthContext } from "@/context/auth.provider"; + import { QueryOperation } from "./apiComponents"; -import { useLogin } from "@/connections/Login"; export type ApiContext = { fetcherOptions: { @@ -40,7 +41,7 @@ export function useApiContext< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey >(_queryOptions?: Omit, "queryKey" | "queryFn">): ApiContext { - const [, { token }] = useLogin(); + const { token } = useAuthContext(); return { fetcherOptions: { diff --git a/src/generated/apiFetcher.ts b/src/generated/apiFetcher.ts index 2b3b621f2..bcc311b57 100644 --- a/src/generated/apiFetcher.ts +++ b/src/generated/apiFetcher.ts @@ -1,7 +1,6 @@ -import { getAccessToken } from "../admin/apiProvider/utils/token"; +import { AdminTokenStorageKey } from "../admin/apiProvider/utils/token"; import { ApiContext } from "./apiContext"; import FormData from "form-data"; -import Log from "@/utils/log"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL + "/api"; @@ -56,10 +55,10 @@ export async function apiFetch< ...headers }; - const accessToken = typeof window !== "undefined" && getAccessToken(); + const adminToken = typeof window !== "undefined" && localStorage.getItem(AdminTokenStorageKey); - if (!requestHeaders?.Authorization && accessToken) { - requestHeaders.Authorization = `Bearer ${accessToken}`; + if (!requestHeaders?.Authorization && adminToken) { + requestHeaders.Authorization = `Bearer ${adminToken}`; } /** @@ -87,7 +86,9 @@ export async function apiFetch< ...(await response.json()) }; } catch (e) { - Log.error("v1/2 API Fetch error", e); + if (process.env.NODE_ENV === "development") { + console.log("apiFetch", e); + } error = { statusCode: -1 }; @@ -103,7 +104,9 @@ export async function apiFetch< return (await response.blob()) as unknown as TData; } } catch (e) { - Log.error("v1/2 API Fetch error", e); + if (process.env.NODE_ENV === "development") { + console.log("apiFetch", e); + } error = { statusCode: response?.status || -1, //@ts-ignore diff --git a/src/generated/apiSchemas.ts b/src/generated/apiSchemas.ts index 7a38edc4f..2fe52628f 100644 --- a/src/generated/apiSchemas.ts +++ b/src/generated/apiSchemas.ts @@ -23417,155 +23417,3 @@ export type FileResource = { is_public?: boolean; is_cover?: boolean; }; - -export type DashboardIndicatorHectaresRestorationResponse = { - data?: { - restoration_strategies_represented?: { - /** - * Total amount for tree planting projects. - */ - ["tree-planting"]?: number; - /** - * Total amount for projects involving both tree planting and direct seeding. - */ - ["tree-planting,direct-seeding"]?: number; - /** - * Total amount for assisted natural regeneration projects. - */ - ["assisted-natural-regeneration"]?: number; - /** - * Total amount for projects involving both tree planting and assisted natural regeneration. - */ - ["tree-planting,assisted-natural-regeneration"]?: number; - /** - * Total amount for direct seeding projects. - */ - ["direct-seeding"]?: number; - /** - * Total amount for control projects. - */ - control?: number; - /** - * Total amount for projects with no specific restoration category. - */ - ["null"]?: number; - }; - target_land_use_types_represented?: { - /** - * Total amount for projects without a defined land use type. - */ - ["null"]?: number; - /** - * Total amount for projects involving natural forest. - */ - ["natural-forest"]?: number; - /** - * Total amount for agroforest projects. - */ - agroforest?: number; - /** - * Total amount for silvopasture projects. - */ - silvopasture?: number; - /** - * Total amount for woodlot or plantation projects. - */ - ["woodlot-or-plantation"]?: number; - /** - * Total amount for riparian area or wetland projects. - */ - ["riparian-area-or-wetland"]?: number; - /** - * Total amount for projects involving both agroforest and riparian area or wetland. - */ - ["agroforest,riparian-area-or-wetland"]?: number; - /** - * Total amount for projects involving both riparian area or wetland and woodlot or plantation. - */ - ["riparian-area-or-wetland,woodlot-or-plantation"]?: number; - /** - * Total amount for projects involving open natural ecosystem or grasslands. - */ - ["Open natural ecosystem or Grasslands"]?: number; - /** - * Total amount for urban forest projects. - */ - ["urban-forest"]?: number; - }; - }; -}; - -export type DashboardIndicatorHectaresRestorationData = { - restoration_strategies_represented?: { - /** - * Total amount for tree planting projects. - */ - ["tree-planting"]?: number; - /** - * Total amount for projects involving both tree planting and direct seeding. - */ - ["tree-planting,direct-seeding"]?: number; - /** - * Total amount for assisted natural regeneration projects. - */ - ["assisted-natural-regeneration"]?: number; - /** - * Total amount for projects involving both tree planting and assisted natural regeneration. - */ - ["tree-planting,assisted-natural-regeneration"]?: number; - /** - * Total amount for direct seeding projects. - */ - ["direct-seeding"]?: number; - /** - * Total amount for control projects. - */ - control?: number; - /** - * Total amount for projects with no specific restoration category. - */ - ["null"]?: number; - }; - target_land_use_types_represented?: { - /** - * Total amount for projects without a defined land use type. - */ - ["null"]?: number; - /** - * Total amount for projects involving natural forest. - */ - ["natural-forest"]?: number; - /** - * Total amount for agroforest projects. - */ - agroforest?: number; - /** - * Total amount for silvopasture projects. - */ - silvopasture?: number; - /** - * Total amount for woodlot or plantation projects. - */ - ["woodlot-or-plantation"]?: number; - /** - * Total amount for riparian area or wetland projects. - */ - ["riparian-area-or-wetland"]?: number; - /** - * Total amount for projects involving both agroforest and riparian area or wetland. - */ - ["agroforest,riparian-area-or-wetland"]?: number; - /** - * Total amount for projects involving both riparian area or wetland and woodlot or plantation. - */ - ["riparian-area-or-wetland,woodlot-or-plantation"]?: number; - /** - * Total amount for projects involving open natural ecosystem or grasslands. - */ - ["Open natural ecosystem or Grasslands"]?: number; - /** - * Total amount for urban forest projects. - */ - ["urban-forest"]?: number; - }; -}; diff --git a/src/generated/v3/userService/userServiceComponents.ts b/src/generated/v3/userService/userServiceComponents.ts deleted file mode 100644 index a25e014a0..000000000 --- a/src/generated/v3/userService/userServiceComponents.ts +++ /dev/null @@ -1,238 +0,0 @@ -/** - * Generated by @openapi-codegen - * - * @version 1.0 - */ -import type * as Fetcher from "./userServiceFetcher"; -import { userServiceFetch } from "./userServiceFetcher"; -import type * as Schemas from "./userServiceSchemas"; - -export type AuthLoginError = Fetcher.ErrorWrapper<{ - status: 401; - payload: { - /** - * @example 401 - */ - statusCode: number; - /** - * @example Unauthorized - */ - message: string; - /** - * @example Unauthorized - */ - error?: string; - }; -}>; - -export type AuthLoginResponse = { - data?: { - /** - * @example logins - */ - type?: string; - /** - * @pattern ^\d{5}$ - */ - id?: string; - attributes?: Schemas.LoginDto; - }; -}; - -export type AuthLoginVariables = { - body: Schemas.LoginRequest; -}; - -/** - * Receive a JWT Token in exchange for login credentials - */ -export const authLogin = (variables: AuthLoginVariables, signal?: AbortSignal) => - userServiceFetch({ - url: "/auth/v3/logins", - method: "post", - ...variables, - signal - }); - -export type UsersFindPathParams = { - /** - * A valid user id or "me" - */ - id: string; -}; - -export type UsersFindError = Fetcher.ErrorWrapper< - | { - status: 401; - payload: { - /** - * @example 401 - */ - statusCode: number; - /** - * @example Unauthorized - */ - message: string; - /** - * @example Unauthorized - */ - error?: string; - }; - } - | { - status: 404; - payload: { - /** - * @example 404 - */ - statusCode: number; - /** - * @example Not Found - */ - message: string; - /** - * @example Not Found - */ - error?: string; - }; - } ->; - -export type UsersFindResponse = { - data?: { - /** - * @example users - */ - type?: string; - /** - * @format uuid - */ - id?: string; - attributes?: Schemas.UserDto; - relationships?: { - org?: { - /** - * @example organisations - */ - type?: string; - /** - * @format uuid - */ - id?: string; - meta?: { - userStatus?: "approved" | "requested" | "rejected" | "na"; - }; - }; - }; - }; - included?: { - /** - * @example organisations - */ - type?: string; - /** - * @format uuid - */ - id?: string; - attributes?: Schemas.OrganisationDto; - }[]; -}; - -export type UsersFindVariables = { - pathParams: UsersFindPathParams; -}; - -/** - * Fetch a user by ID, or with the 'me' identifier - */ -export const usersFind = (variables: UsersFindVariables, signal?: AbortSignal) => - userServiceFetch({ - url: "/users/v3/users/{id}", - method: "get", - ...variables, - signal - }); - -export type HealthControllerCheckError = Fetcher.ErrorWrapper<{ - status: 503; - payload: { - /** - * @example error - */ - status?: string; - /** - * @example {"database":{"status":"up"}} - */ - info?: { - [key: string]: { - status: string; - } & { - [key: string]: any; - }; - } | null; - /** - * @example {"redis":{"status":"down","message":"Could not connect"}} - */ - error?: { - [key: string]: { - status: string; - } & { - [key: string]: any; - }; - } | null; - /** - * @example {"database":{"status":"up"},"redis":{"status":"down","message":"Could not connect"}} - */ - details?: { - [key: string]: { - status: string; - } & { - [key: string]: any; - }; - }; - }; -}>; - -export type HealthControllerCheckResponse = { - /** - * @example ok - */ - status?: string; - /** - * @example {"database":{"status":"up"}} - */ - info?: { - [key: string]: { - status: string; - } & { - [key: string]: any; - }; - } | null; - /** - * @example {} - */ - error?: { - [key: string]: { - status: string; - } & { - [key: string]: any; - }; - } | null; - /** - * @example {"database":{"status":"up"}} - */ - details?: { - [key: string]: { - status: string; - } & { - [key: string]: any; - }; - }; -}; - -export const healthControllerCheck = (signal?: AbortSignal) => - userServiceFetch({ - url: "/health", - method: "get", - signal - }); diff --git a/src/generated/v3/userService/userServiceFetcher.ts b/src/generated/v3/userService/userServiceFetcher.ts deleted file mode 100644 index add02a4ee..000000000 --- a/src/generated/v3/userService/userServiceFetcher.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This type is imported in the auto generated `userServiceComponents` file, so it needs to be -// exported from this file. -export type { ErrorWrapper } from "../utils"; - -// The serviceFetch method is the shared fetch method for all service fetchers. -export { serviceFetch as userServiceFetch } from "../utils"; diff --git a/src/generated/v3/userService/userServicePredicates.ts b/src/generated/v3/userService/userServicePredicates.ts deleted file mode 100644 index 8c16e14db..000000000 --- a/src/generated/v3/userService/userServicePredicates.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { isFetching, fetchFailed } from "../utils"; -import { ApiDataStore } from "@/store/apiSlice"; -import { UsersFindPathParams, UsersFindVariables } from "./userServiceComponents"; - -export const authLoginIsFetching = (store: ApiDataStore) => - isFetching<{}, {}>({ store, url: "/auth/v3/logins", method: "post" }); - -export const authLoginFetchFailed = (store: ApiDataStore) => - fetchFailed<{}, {}>({ store, url: "/auth/v3/logins", method: "post" }); - -export const usersFindIsFetching = (variables: UsersFindVariables) => (store: ApiDataStore) => - isFetching<{}, UsersFindPathParams>({ store, url: "/users/v3/users/{id}", method: "get", ...variables }); - -export const usersFindFetchFailed = (variables: UsersFindVariables) => (store: ApiDataStore) => - fetchFailed<{}, UsersFindPathParams>({ store, url: "/users/v3/users/{id}", method: "get", ...variables }); - -export const healthControllerCheckIsFetching = (store: ApiDataStore) => - isFetching<{}, {}>({ store, url: "/health", method: "get" }); - -export const healthControllerCheckFetchFailed = (store: ApiDataStore) => - fetchFailed<{}, {}>({ store, url: "/health", method: "get" }); diff --git a/src/generated/v3/userService/userServiceSchemas.ts b/src/generated/v3/userService/userServiceSchemas.ts deleted file mode 100644 index 5c8c36617..000000000 --- a/src/generated/v3/userService/userServiceSchemas.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Generated by @openapi-codegen - * - * @version 1.0 - */ -export type LoginDto = { - /** - * JWT token for use in future authenticated requests to the API. - * - * @example - */ - token: string; -}; - -export type LoginRequest = { - emailAddress: string; - password: string; -}; - -export type UserFramework = { - /** - * @example TerraFund Landscapes - */ - name: string; - /** - * @example terrafund-landscapes - */ - slug: string; -}; - -export type UserDto = { - uuid: string; - firstName: string | null; - lastName: string | null; - /** - * Currently just calculated by appending lastName to firstName. - */ - fullName: string | null; - primaryRole: string; - /** - * @example person@foocorp.net - */ - emailAddress: string; - /** - * @format date-time - */ - emailAddressVerifiedAt: string | null; - locale: string | null; - frameworks: UserFramework[]; -}; - -export type OrganisationDto = { - status: "draft" | "pending" | "approved" | "rejected"; - name: string | null; -}; diff --git a/src/generated/v3/utils.ts b/src/generated/v3/utils.ts deleted file mode 100644 index 6378cd64c..000000000 --- a/src/generated/v3/utils.ts +++ /dev/null @@ -1,157 +0,0 @@ -import ApiSlice, { ApiDataStore, isErrorState, isInProgress, Method, PendingErrorState } from "@/store/apiSlice"; -import Log from "@/utils/log"; -import { selectLogin } from "@/connections/Login"; - -const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; - -export type ErrorWrapper = TError | { statusCode: -1; message: string }; - -type SelectorOptions = { - store: ApiDataStore; - url: string; - method: string; - queryParams?: TQueryParams; - pathParams?: TPathParams; -}; - -export const resolveUrl = ( - url: string, - queryParams: Record = {}, - pathParams: Record = {} -) => { - const searchParams = new URLSearchParams(queryParams); - // Make sure the output string always ends up in the same order because we need the URL string - // that is generated from a set of query / path params to be consistent even if the order of the - // params in the source object changes. - searchParams.sort(); - let query = searchParams.toString(); - if (query) query = `?${query}`; - return `${baseUrl}${url.replace(/\{\w*}/g, key => pathParams[key.slice(1, -1)]) + query}`; -}; - -export function isFetching({ - store, - url, - method, - pathParams, - queryParams -}: SelectorOptions): boolean { - const fullUrl = resolveUrl(url, queryParams, pathParams); - const pending = store.meta.pending[method.toUpperCase() as Method][fullUrl]; - return isInProgress(pending); -} - -export function fetchFailed({ - store, - url, - method, - pathParams, - queryParams -}: SelectorOptions): PendingErrorState | null { - const fullUrl = resolveUrl(url, queryParams, pathParams); - const pending = store.meta.pending[method.toUpperCase() as Method][fullUrl]; - return isErrorState(pending) ? pending : null; -} - -const isPending = (method: Method, fullUrl: string) => ApiSlice.apiDataStore.meta.pending[method][fullUrl] != null; - -async function dispatchRequest(url: string, requestInit: RequestInit) { - const actionPayload = { url, method: requestInit.method as Method }; - ApiSlice.fetchStarting(actionPayload); - - try { - const response = await fetch(url, requestInit); - - if (!response.ok) { - const error = (await response.json()) as ErrorWrapper; - ApiSlice.fetchFailed({ ...actionPayload, error: error as PendingErrorState }); - return; - } - - if (!response.headers.get("content-type")?.includes("json")) { - // this API integration only supports JSON type responses at the moment. - throw new Error(`Response type is not JSON [${response.headers.get("content-type")}]`); - } - - const responsePayload = await response.json(); - if (responsePayload.statusCode != null && responsePayload.message != null) { - ApiSlice.fetchFailed({ ...actionPayload, error: responsePayload }); - } else { - ApiSlice.fetchSucceeded({ ...actionPayload, response: responsePayload }); - } - } catch (e) { - Log.error("Unexpected API fetch failure", e); - const message = e instanceof Error ? `Network error (${e.message})` : "Network error"; - ApiSlice.fetchFailed({ ...actionPayload, error: { statusCode: -1, message } }); - } -} - -export type ServiceFetcherOptions = { - url: string; - method: string; - body?: TBody; - headers?: THeaders; - queryParams?: TQueryParams; - pathParams?: TPathParams; - signal?: AbortSignal; -}; - -export function serviceFetch< - TData, - TError, - TBody extends {} | FormData | undefined | null, - THeaders extends {}, - TQueryParams extends {}, - TPathParams extends {} ->({ - url, - method: methodString, - body, - headers, - pathParams, - queryParams, - signal -}: ServiceFetcherOptions) { - const fullUrl = resolveUrl(url, queryParams, pathParams); - const method = methodString.toUpperCase() as Method; - if (isPending(method, fullUrl)) { - // Ignore requests to issue an API request that is in progress or has failed without a cache - // clear. - return; - } - - const requestHeaders: HeadersInit = { - "Content-Type": "application/json", - ...headers - }; - - // Note: there's a race condition that I haven't figured out yet: the middleware in apiSlice that - // sets the access token in localStorage is firing _after_ the action has been merged into the - // store, which means that the next connections that kick off right away don't have access to - // the token through the getAccessToken method. So, we grab it from the store instead, which is - // more reliable in this case. - const { token } = selectLogin(); - if (!requestHeaders?.Authorization && token != null) { - // Always include the JWT access token if we have one. - requestHeaders.Authorization = `Bearer ${token}`; - } - - /** - * As the fetch API is being used, when multipart/form-data is specified - * the Content-Type header must be deleted so that the browser can set - * the correct boundary. - * https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object - */ - if (requestHeaders["Content-Type"].toLowerCase().includes("multipart/form-data")) { - delete requestHeaders["Content-Type"]; - } - - // The promise is ignored on purpose. Further progress of the request is tracked through - // redux. - dispatchRequest(fullUrl, { - signal, - method, - body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined, - headers: requestHeaders - }); -} diff --git a/src/hooks/logout.ts b/src/hooks/logout.ts index a9302d633..ec9834f37 100644 --- a/src/hooks/logout.ts +++ b/src/hooks/logout.ts @@ -1,13 +1,17 @@ import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/router"; -import { logout } from "@/connections/Login"; +import { removeAccessToken } from "@/admin/apiProvider/utils/token"; export const useLogout = () => { const queryClient = useQueryClient(); + const router = useRouter(); return () => { queryClient.getQueryCache().clear(); queryClient.clear(); - logout(); + removeAccessToken(); + router.push("/"); + window.location.replace("/"); }; }; diff --git a/src/hooks/useConnection.test.tsx b/src/hooks/useConnection.test.tsx deleted file mode 100644 index 553412273..000000000 --- a/src/hooks/useConnection.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import { ReactNode, useMemo } from "react"; -import { act } from "react-dom/test-utils"; -import { Provider as ReduxProvider } from "react-redux"; -import { createSelector } from "reselect"; - -import { AuthLoginResponse } from "@/generated/v3/userService/userServiceComponents"; -import { useConnection } from "@/hooks/useConnection"; -import ApiSlice, { ApiDataStore, JsonApiResource } from "@/store/apiSlice"; -import { makeStore } from "@/store/store"; -import { Connection } from "@/types/connection"; - -const StoreWrapper = ({ children }: { children: ReactNode }) => { - const store = useMemo(() => makeStore(), []); - return {children}; -}; - -describe("Test useConnection hook", () => { - test("isLoaded", () => { - const load = jest.fn(); - let connectionLoaded = false; - const connection = { - selector: () => ({ connectionLoaded }), - load, - isLoaded: ({ connectionLoaded }) => connectionLoaded - } as Connection<{ connectionLoaded: boolean }>; - let rendered = renderHook(() => useConnection(connection), { wrapper: StoreWrapper }); - - expect(rendered.result.current[0]).toBe(false); - expect(load).toHaveBeenCalled(); - - load.mockReset(); - connectionLoaded = true; - rendered = renderHook(() => useConnection(connection), { wrapper: StoreWrapper }); - expect(rendered.result.current[0]).toBe(true); - expect(load).toHaveBeenCalled(); - }); - - test("selector efficiency", () => { - const selector = jest.fn(({ logins }: ApiDataStore) => logins); - const payloadCreator = jest.fn(logins => { - const values = Object.values(logins); - return { login: values.length < 1 ? null : values[0] }; - }); - const connection = { - selector: createSelector([selector], payloadCreator) - } as Connection<{ login: AuthLoginResponse }>; - - const { result, rerender } = renderHook(() => useConnection(connection), { wrapper: StoreWrapper }); - rerender(); - - expect(result.current[1]).toStrictEqual({ login: null }); - // The rerender doesn't cause an additional call to either function because the input (the - // redux store) didn't change. - expect(selector).toHaveBeenCalledTimes(1); - expect(payloadCreator).toHaveBeenCalledTimes(1); - - const token = "asdfasdfasdf"; - const data = { type: "logins", id: "1", attributes: { token } } as JsonApiResource; - act(() => { - ApiSlice.fetchSucceeded({ url: "/foo", method: "POST", response: { data } }); - }); - - // The store has changed so the selector gets called again, and the selector's result has - // changed so the payload creator gets called again, and returns the new Login response that - // was saved in the store. - expect(result.current[1]).toStrictEqual({ login: { attributes: { token } } }); - expect(selector).toHaveBeenCalledTimes(2); - expect(payloadCreator).toHaveBeenCalledTimes(2); - - rerender(); - // The store didn't change, so neither function gets called. - expect(selector).toHaveBeenCalledTimes(2); - expect(payloadCreator).toHaveBeenCalledTimes(2); - - act(() => { - ApiSlice.fetchStarting({ url: "/bar", method: "POST" }); - }); - // The store has changed, so the selector gets called again, but the selector's result is - // the same so the payload creator does not get called again, and returns its memoized result. - expect(selector).toHaveBeenCalledTimes(3); - expect(payloadCreator).toHaveBeenCalledTimes(2); - }); -}); diff --git a/src/hooks/useConnection.ts b/src/hooks/useConnection.ts deleted file mode 100644 index ee7db446a..000000000 --- a/src/hooks/useConnection.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { useStore } from "react-redux"; - -import { AppStore } from "@/store/store"; -import { Connected, Connection, OptionalProps } from "@/types/connection"; - -/** - * Use a connection to efficiently depend on data in the Redux store. - * - * In this hook, an internal subscription to the store is used instead of a useSelector() on the - * whole API state. This limits redraws of the component to the times that the connected state of - * the Connection changes. - */ -export function useConnection( - connection: Connection, - props: TProps | Record = {} -): Connected { - const { selector, isLoaded, load } = connection; - const store = useStore(); - - const getConnected = useCallback(() => { - const connected = selector(store.getState().api, props); - const loadingDone = isLoaded == null || isLoaded(connected, props); - return { loadingDone, connected }; - }, [store, isLoaded, props, selector]); - - const [connected, setConnected] = useState(() => { - const { loadingDone, connected } = getConnected(); - return loadingDone ? connected : null; - }); - - useEffect( - () => { - function checkState() { - const { loadingDone, connected: currentConnected } = getConnected(); - if (load != null) load(currentConnected, props); - if (loadingDone) { - setConnected(currentConnected); - } else { - // In case something was loaded and then got unloaded via a redux store clear - if (connected != null) setConnected(null); - } - } - - const subscription = store.subscribe(checkState); - checkState(); - return subscription; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [connection, ...Object.keys(props ?? [])] - ); - - return connected == null ? [false, {}] : [true, connected]; -} diff --git a/src/hooks/useGetCustomFormSteps/useGetCustomFormSteps.stories.tsx b/src/hooks/useGetCustomFormSteps/useGetCustomFormSteps.stories.tsx index 05d4dec17..b7ac916d8 100644 --- a/src/hooks/useGetCustomFormSteps/useGetCustomFormSteps.stories.tsx +++ b/src/hooks/useGetCustomFormSteps/useGetCustomFormSteps.stories.tsx @@ -4,7 +4,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import WizardForm, { WizardFormProps } from "@/components/extensive/WizardForm"; import { FormRead } from "@/generated/apiSchemas"; import { getCustomFormSteps } from "@/helpers/customForms"; -import Log from "@/utils/log"; import formSchema from "./formSchema.json"; @@ -28,8 +27,8 @@ export const WithGetFormStepHook: Story = { ), args: { steps: getCustomFormSteps(formSchema as FormRead, (t: any) => t), - onStepChange: Log.info, - onChange: Log.info, + onStepChange: console.log, + onChange: console.log, nextButtonText: "Save and Continue", submitButtonText: "Submit", hideBackButton: false, diff --git a/src/hooks/useMessageValidations.ts b/src/hooks/useMessageValidations.ts index f88dc552a..1bacd8602 100644 --- a/src/hooks/useMessageValidations.ts +++ b/src/hooks/useMessageValidations.ts @@ -1,8 +1,6 @@ import { useT } from "@transifex/react"; import { useMemo } from "react"; -import Log from "@/utils/log"; - interface IntersectionInfo { intersectSmaller: boolean; percentage: number; @@ -53,7 +51,7 @@ export const useMessageValidators = () => { }); }); } catch (error) { - Log.error("Failed to get intersection messages", error); + console.error(error); return [t("Error parsing extra info.")]; } }, diff --git a/src/hooks/useMyOrg.ts b/src/hooks/useMyOrg.ts new file mode 100644 index 000000000..0f45c8643 --- /dev/null +++ b/src/hooks/useMyOrg.ts @@ -0,0 +1,21 @@ +import { UserRead, V2MonitoringOrganisationRead } from "@/generated/apiSchemas"; +import { useUserData } from "@/hooks/useUserData"; + +/** + * to get current user organisation + * @returns V2MonitoringOrganisationRead user organisation + */ +export const useMyOrg = () => { + const userData = useUserData(); + + if (userData) { + return getMyOrg(userData); + } else { + return null; + } +}; + +export const getMyOrg = (userData: UserRead): V2MonitoringOrganisationRead | undefined => { + //@ts-ignore + return userData?.organisation; +}; diff --git a/src/hooks/useUserData.ts b/src/hooks/useUserData.ts new file mode 100644 index 000000000..ef1df550c --- /dev/null +++ b/src/hooks/useUserData.ts @@ -0,0 +1,20 @@ +import { useAuthContext } from "@/context/auth.provider"; +import { useGetAuthMe } from "@/generated/apiComponents"; +import { MeResponse } from "@/generated/apiSchemas"; + +/** + * To easily access user data + * @returns MeResponse + */ +export const useUserData = () => { + const { token } = useAuthContext(); + const { data: authMe } = useGetAuthMe<{ data: MeResponse }>( + {}, + { + enabled: !!token, + staleTime: 300_000 //Data considered fresh for 5 min to prevent excess api call + } + ); + + return authMe?.data || null; +}; diff --git a/src/hooks/useValueChanged.ts b/src/hooks/useValueChanged.ts deleted file mode 100644 index d06a0f793..000000000 --- a/src/hooks/useValueChanged.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useEffect, useRef } from "react"; - -/** - * A hook useful for executing a side effect after component render (in an effect) if the given - * value changes. Uses strict equals. The primary use of this hook is to prevent a side effect from - * being executed multiple times if the component re-renders after the value has transitioned to its - * action state. - * - * Callback is guaranteed to execute on the first render of the component. This is intentional. A - * consumer of this hook is expected to check the current state of the value and take action based - * on its current state. If the component initially renders with the value in the action state, we'd - * want the resulting side effect to take place immediately, rather than only when the value has - * changed. - * - * Example: - * - * useValueChanged(isLoggedIn, () => { - * if (isLoggedIn) router.push("/home"); - * } - */ -export function useValueChanged(value: T, callback: () => void) { - const ref = useRef(); - useEffect(() => { - if (ref.current !== value) { - ref.current = value; - callback(); - } - }); -} diff --git a/src/middleware.page.ts b/src/middleware.page.ts index 28a01f9d5..c1c0e5c13 100644 --- a/src/middleware.page.ts +++ b/src/middleware.page.ts @@ -2,8 +2,9 @@ import * as Sentry from "@sentry/nextjs"; import { NextRequest } from "next/server"; import { isAdmin, UserRole } from "@/admin/apiProvider/utils/user"; -import { OrganisationDto, UserDto } from "@/generated/v3/userService/userServiceSchemas"; -import { resolveUrl } from "@/generated/v3/utils"; +import { fetchGetAuthMe } from "@/generated/apiComponents"; +import { UserRead } from "@/generated/apiSchemas"; +import { getMyOrg } from "@/hooks/useMyOrg"; import { MiddlewareCacheKey, MiddlewareMatcher } from "@/utils/MiddlewareMatcher"; //Todo: refactor this logic somewhere down the line as there are lot's of if/else nested! @@ -36,59 +37,49 @@ export async function middleware(request: NextRequest) { matcher.redirect("/auth/login"); }, async () => { - // The redux store isn't available yet at this point, so we do a quick manual users/me fetch - // to get the data we need to resolve routing. - const result = await fetch(resolveUrl("/users/v3/users/me"), { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${accessToken}` - } - }); - const json = await result.json(); + //Logged-in + const response = (await fetchGetAuthMe({ + headers: { Authorization: `Bearer ${accessToken}` } + })) as { data: UserRead }; - const user = json.data.attributes as UserDto; - const { - id: organisationId, - meta: { userStatus } - } = json.data.relationships?.org?.data ?? { meta: {} }; - const organisation: OrganisationDto | undefined = json.included?.[0]?.attributes; + const userData = response.data; matcher.if( - !user?.emailAddressVerifiedAt, + !userData?.email_address_verified_at, () => { //Email is not verified - matcher.redirect(`/auth/signup/confirm?email=${user?.emailAddress}`); + matcher.redirect(`/auth/signup/confirm?email=${userData.email_address}`); }, () => { //Email is verified - const userIsAdmin = isAdmin(user?.primaryRole as UserRole); + //@ts-ignore + const myOrg = userData && getMyOrg(userData); + const userIsAdmin = isAdmin(userData?.role as UserRole); - matcher.when(user != null && userIsAdmin)?.redirect(`/admin`, { cacheResponse: true }); + matcher.when(!!userData && userIsAdmin)?.redirect(`/admin`, { cacheResponse: true }); matcher - .when(organisation != null && organisation.status !== "draft") + .when(!!myOrg && !!myOrg?.status && myOrg?.status !== "draft") ?.startWith("/organization/create") ?.redirect(`/organization/create/confirm`); - matcher.when(organisation == null)?.redirect(`/organization/assign`); - - matcher.when(organisation?.status === "draft")?.redirect(`/organization/create`); + matcher.when(!myOrg)?.redirect(`/organization/assign`); - matcher.when(userStatus === "requested")?.redirect(`/organization/status/pending`); + matcher.when(!!myOrg && (!myOrg?.status || myOrg?.status === "draft"))?.redirect(`/organization/create`); matcher - .when(organisationId != null) - ?.exact("/organization") - ?.redirect(`/organization/${organisationId}`); + .when(!!myOrg && !!myOrg?.users_status && myOrg?.users_status === "requested") + ?.redirect(`/organization/status/pending`); + + matcher.when(!!myOrg)?.exact("/organization")?.redirect(`/organization/${myOrg?.uuid}`); - matcher.when(organisation?.status === "rejected")?.redirect(`/organization/status/rejected`); + matcher.when(!!myOrg && myOrg?.status === "rejected")?.redirect(`/organization/status/rejected`); matcher.exact("/")?.redirect(`/home`); matcher.startWith("/auth")?.redirect("/home"); - if (!userIsAdmin && organisation?.status === "approved" && userStatus !== "requested") { + if (!userIsAdmin && !!myOrg && myOrg.status === "approved" && myOrg?.users_status !== "requested") { //Cache result if user has and approved org matcher.next().cache("/home"); } else { diff --git a/src/middleware.test.ts b/src/middleware.test.ts index a178dec33..d6a05dfdb 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -4,13 +4,18 @@ import { NextRequest, NextResponse } from "next/server"; -import { OrganisationDto, UserDto } from "@/generated/v3/userService/userServiceSchemas"; +import * as api from "@/generated/apiComponents"; import { middleware } from "./middleware.page"; //@ts-ignore Headers.prototype.getAll = () => []; //To fix TypeError: this._headers.getAll is not a function +jest.mock("@/generated/apiComponents", () => ({ + __esModule: true, + fetchGetAuthMe: jest.fn() +})); + const domain = "https://localhost:3000"; const getRequest = (url: string, loggedIn?: boolean, cachedUrl?: string) => { @@ -82,44 +87,20 @@ describe("User is not Logged In", () => { }); }); -function mockUsersMe( - userAttributes: Partial, - org: { - attributes?: Partial; - id?: string; - userStatus?: string; - } = {} -) { - jest.spyOn(global, "fetch").mockImplementation(() => - Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ - data: { - attributes: userAttributes, - relationships: { - org: { - data: { - id: org.id, - meta: { userStatus: org.userStatus } - } - } - } - }, - included: [{ attributes: org.attributes }] - }) - } as Response) - ); -} - describe("User is Logged In and not verified", () => { - afterEach(() => { + beforeEach(() => { jest.resetAllMocks(); }); it("redirect not verified users to /auth/signup/confirm", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - mockUsersMe({ emailAddress: "test@example.com" }); + //@ts-ignore + api.fetchGetAuthMe.mockResolvedValue({ + data: { + email_address: "test@example.com", + email_address_verified_at: null + } + }); await middleware(getRequest("/", true)); await testMultipleRoute(spy, `/auth/signup/confirm?email=test@example.com`); @@ -127,13 +108,21 @@ describe("User is Logged In and not verified", () => { }); describe("User is Logged In and verified", () => { - afterEach(() => { + beforeEach(() => { jest.resetAllMocks(); }); it("redirect routes that start with /organization/create to /organization/create/confirm when org has been approved", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { attributes: { status: "approved" } }); + //@ts-ignore + api.fetchGetAuthMe.mockResolvedValue({ + data: { + email_address_verified_at: "2023-02-17T10:54:16.000000Z", + organisation: { + status: "approved" + } + } + }); await middleware(getRequest("/organization/create/test", true)); expect(spy).toBeCalledWith(new URL("/organization/create/confirm", domain)); @@ -144,45 +133,86 @@ describe("User is Logged In and verified", () => { it("redirect any route to /admin when user is an admin", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z", primaryRole: "admin-super" }); - + //@ts-ignore + api.fetchGetAuthMe.mockResolvedValue({ + data: { + email_address_verified_at: "2023-02-17T10:54:16.000000Z", + organisation: null, + role: "admin-super" + } + }); await testMultipleRoute(spy, "/admin"); }); it("redirect any route to /organization/assign when org does not exist", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }); - + //@ts-ignore + api.fetchGetAuthMe.mockResolvedValue({ + data: { + email_address_verified_at: "2023-02-17T10:54:16.000000Z", + organisation: null + } + }); await testMultipleRoute(spy, "/organization/assign"); }); it("redirect any route to /organization/create when org is a draft", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { attributes: { status: "draft" } }); - + //@ts-ignore + api.fetchGetAuthMe.mockResolvedValue({ + data: { + email_address_verified_at: "2023-02-17T10:54:16.000000Z", + organisation: { + status: "draft" + } + } + }); await testMultipleRoute(spy, "/organization/create"); }); it("redirect any route to /organization/status/pending when user is awaiting org approval", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { userStatus: "requested" }); + //@ts-ignore + api.fetchGetAuthMe.mockResolvedValue({ + data: { + email_address_verified_at: "2023-02-17T10:54:16.000000Z", + organisation: { + users_status: "requested" + } + } + }); await testMultipleRoute(spy, "/organization/status/pending"); }); it("redirect any route to /organization/status/rejected when user is rejected", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { attributes: { status: "rejected" } }); + //@ts-ignore + api.fetchGetAuthMe.mockResolvedValue({ + data: { + email_address_verified_at: "2023-02-17T10:54:16.000000Z", + organisation: { + status: "rejected" + } + } + }); await testMultipleRoute(spy, "/organization/status/rejected"); }); it("redirect /organization to /organization/[org_uuid]", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - mockUsersMe( - { emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, - { attributes: { status: "approved" }, id: "uuid" } - ); + //@ts-ignore + api.fetchGetAuthMe.mockResolvedValue({ + data: { + email_address_verified_at: "2023-02-17T10:54:16.000000Z", + organisation: { + status: "approved", + name: "", + uuid: "uuid" + } + } + }); await middleware(getRequest("/organization", true)); @@ -191,7 +221,16 @@ describe("User is Logged In and verified", () => { it("redirect / to /home", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { attributes: { status: "approved" } }); + //@ts-ignore + api.fetchGetAuthMe.mockResolvedValue({ + data: { + email_address_verified_at: "2023-02-17T10:54:16.000000Z", + organisation: { + status: "approved", + name: "" + } + } + }); await middleware(getRequest("/", true)); @@ -200,7 +239,16 @@ describe("User is Logged In and verified", () => { it("redirect routes that startWith /auth to /home", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { attributes: { status: "approved" } }); + //@ts-ignore + api.fetchGetAuthMe.mockResolvedValue({ + data: { + email_address_verified_at: "2023-02-17T10:54:16.000000Z", + organisation: { + status: "approved", + name: "" + } + } + }); await middleware(getRequest("/auth", true)); expect(spy).toBeCalledWith(new URL("/home", domain)); diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index e823f7905..bc2429bd4 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -9,14 +9,12 @@ import dynamic from "next/dynamic"; import { useRouter } from "next/router"; import nookies from "nookies"; import { Else, If, Then } from "react-if"; -import { Provider as ReduxProvider } from "react-redux"; import Toast from "@/components/elements/Toast/Toast"; import ModalRoot from "@/components/extensive/Modal/ModalRoot"; import DashboardLayout from "@/components/generic/Layout/DashboardLayout"; import MainLayout from "@/components/generic/Layout/MainLayout"; -import { loadLogin } from "@/connections/Login"; -import { loadMyUser } from "@/connections/User"; +import AuthProvider from "@/context/auth.provider"; import { LoadingProvider } from "@/context/loaderAdmin.provider"; import ModalProvider from "@/context/modal.provider"; import NavbarProvider from "@/context/navbar.provider"; @@ -25,95 +23,89 @@ import WrappedQueryClientProvider from "@/context/queryclient.provider"; import RouteHistoryProvider from "@/context/routeHistory.provider"; import ToastProvider from "@/context/toast.provider"; import { getServerSideTranslations, setClientSideTranslations } from "@/i18n"; -import { apiSlice } from "@/store/apiSlice"; -import { wrapper } from "@/store/store"; -import Log from "@/utils/log"; import setupYup from "@/yup.locale"; const CookieBanner = dynamic(() => import("@/components/extensive/CookieBanner/CookieBanner"), { ssr: false }); -const _App = ({ Component, ...rest }: AppProps) => { +const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessToken?: string; props: any }) => { const t = useT(); const router = useRouter(); const isAdmin = router.asPath.includes("/admin"); const isOnDashboards = router.asPath.includes("/dashboard"); - const { store, props } = wrapper.useWrappedStore(rest); - setClientSideTranslations(props); setupYup(t); if (isAdmin) return ( - + <> - - - - - - - - + + + + + + + + + + - + ); else return ( - + <> - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + ); }; -_App.getInitialProps = wrapper.getInitialAppProps(store => async (context: AppContext) => { - const authToken = nookies.get(context.ctx).accessToken; - if (authToken != null && (await loadLogin()).token !== authToken) { - store.dispatch(apiSlice.actions.setInitialAuthToken({ authToken })); - await loadMyUser(); - } - +_App.getInitialProps = async (context: AppContext) => { const ctx = await App.getInitialProps(context); + const cookies = nookies.get(context.ctx); let translationsData = {}; try { translationsData = await getServerSideTranslations(context.ctx); } catch (err) { - Log.warn("Failed to get Serverside Transifex", err); + console.log("Failed to get Serverside Transifex", err); } - return { ...ctx, props: { ...translationsData } }; -}); + return { ...ctx, props: { ...translationsData }, accessToken: cookies.accessToken }; +}; export default _App; diff --git a/src/pages/api/auth/login.tsx b/src/pages/api/auth/login.tsx new file mode 100644 index 000000000..fc2f50a5e --- /dev/null +++ b/src/pages/api/auth/login.tsx @@ -0,0 +1,17 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { setCookie } from "nookies"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.send("only POST"); + + const token = req.body.token; + + setCookie({ res }, "accessToken", token, { + maxAge: 60 * 60 * 12, // 12 hours + // httpOnly: true, + secure: process.env.NODE_ENV !== "development", + path: "/" + }); + + res.status(200).json({ success: true }); +} diff --git a/src/pages/api/auth/logout.tsx b/src/pages/api/auth/logout.tsx new file mode 100644 index 000000000..3ccb00eda --- /dev/null +++ b/src/pages/api/auth/logout.tsx @@ -0,0 +1,9 @@ +import { NextApiRequest, NextApiResponse } from "next"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") return res.send("only POST"); + + res.setHeader("Set-Cookie", "accessToken=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"); + + res.status(200).json({ success: true }); +} diff --git a/src/pages/applications/components/ApplicationHeader.tsx b/src/pages/applications/components/ApplicationHeader.tsx index b9c92fa39..59d5e2b66 100644 --- a/src/pages/applications/components/ApplicationHeader.tsx +++ b/src/pages/applications/components/ApplicationHeader.tsx @@ -4,7 +4,6 @@ import { When } from "react-if"; import Button from "@/components/elements/Button/Button"; import PageHeader from "@/components/extensive/PageElements/Header/PageHeader"; import { fetchGetV2ApplicationsUUIDExport } from "@/generated/apiComponents"; -import Log from "@/utils/log"; import { downloadFileBlob } from "@/utils/network"; interface ApplicationHeaderProps { @@ -26,7 +25,7 @@ const ApplicationHeader = ({ name, status, uuid }: ApplicationHeaderProps) => { if (!res) return; return downloadFileBlob(res, "Application.csv"); } catch (err) { - Log.error("Failed to fetch applications exports", err); + console.log(err); } }; diff --git a/src/pages/auth/login/components/LoginForm.tsx b/src/pages/auth/login/components/LoginForm.tsx index 1aaa74d65..55d8eb74b 100644 --- a/src/pages/auth/login/components/LoginForm.tsx +++ b/src/pages/auth/login/components/LoginForm.tsx @@ -11,7 +11,7 @@ import { LoginFormDataType } from "../index.page"; type LoginFormProps = { form: UseFormReturn; - handleSave: (data: LoginFormDataType) => void; + handleSave: (data: LoginFormDataType) => Promise; loading?: boolean; }; diff --git a/src/pages/auth/login/index.page.tsx b/src/pages/auth/login/index.page.tsx index 3f7ae4213..2fb230992 100644 --- a/src/pages/auth/login/index.page.tsx +++ b/src/pages/auth/login/index.page.tsx @@ -4,10 +4,9 @@ import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import * as yup from "yup"; -import { login, useLogin } from "@/connections/Login"; +import { useAuthContext } from "@/context/auth.provider"; import { ToastType, useToastContext } from "@/context/toast.provider"; import { useSetInviteToken } from "@/hooks/useInviteToken"; -import { useValueChanged } from "@/hooks/useValueChanged"; import LoginLayout from "../layout"; import LoginForm from "./components/LoginForm"; @@ -28,25 +27,35 @@ const LoginPage = () => { useSetInviteToken(); const t = useT(); const router = useRouter(); - const [, { isLoggedIn, isLoggingIn, loginFailed }] = useLogin(); + const { login, loginLoading } = useAuthContext(); const { openToast } = useToastContext(); const form = useForm({ resolver: yupResolver(LoginFormDataSchema(t)), mode: "onSubmit" }); - useValueChanged(loginFailed, () => { - if (loginFailed) openToast(t("Incorrect Email or Password"), ToastType.ERROR); - }); - useValueChanged(isLoggedIn, () => { - if (isLoggedIn) router.push("/home"); - }); - - const handleSave = (data: LoginFormDataType) => login(data.email, data.password); + /** + * Form Submit Handler + * @param data LoginFormData + * @returns Log in user and redirect to homepage + */ + const handleSave = async (data: LoginFormDataType) => { + const res = (await login( + { + email_address: data.email, + password: data.password + }, + () => openToast(t("Incorrect Email or Password"), ToastType.ERROR) + )) as { success: boolean }; + + if (!res?.success) return; + + return router.push("/home"); + }; return ( - + ); }; diff --git a/src/pages/auth/verify/email/[token].page.tsx b/src/pages/auth/verify/email/[token].page.tsx index 27ecaf781..0ff27a388 100644 --- a/src/pages/auth/verify/email/[token].page.tsx +++ b/src/pages/auth/verify/email/[token].page.tsx @@ -9,7 +9,6 @@ import { IconNames } from "@/components/extensive/Icon/Icon"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; import { fetchPatchV2AuthVerify } from "@/generated/apiComponents"; -import Log from "@/utils/log"; const VerifyEmail: NextPage> = () => { const t = useT(); @@ -41,7 +40,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { try { await fetchPatchV2AuthVerify({ body: { token } }); } catch (e) { - Log.error("Failed to verify auth", e); + console.log(e); options = { redirect: { permanent: false, diff --git a/src/pages/debug/index.page.tsx b/src/pages/debug/index.page.tsx index 91a8b728d..ce3cc342d 100644 --- a/src/pages/debug/index.page.tsx +++ b/src/pages/debug/index.page.tsx @@ -8,7 +8,6 @@ import PageBody from "@/components/extensive/PageElements/Body/PageBody"; import PageCard from "@/components/extensive/PageElements/Card/PageCard"; import PageHeader from "@/components/extensive/PageElements/Header/PageHeader"; import PageSection from "@/components/extensive/PageElements/Section/PageSection"; -import Log from "@/utils/log"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL + "/api"; @@ -28,7 +27,7 @@ const DebugPage = () => { }; } catch (e) { if (process.env.NODE_ENV === "development") { - Log.error("apiFetch", e); + console.log("apiFetch", e); } error = { statusCode: -1 @@ -39,7 +38,7 @@ const DebugPage = () => { } } catch (e) { if (process.env.NODE_ENV === "development") { - Log.error("apiFetch", e); + console.log("apiFetch", e); } error = { statusCode: response?.status || -1, diff --git a/src/pages/form/[id]/pitch-select.page.tsx b/src/pages/form/[id]/pitch-select.page.tsx index a3f6c999a..ceeaf2972 100644 --- a/src/pages/form/[id]/pitch-select.page.tsx +++ b/src/pages/form/[id]/pitch-select.page.tsx @@ -13,10 +13,10 @@ import Form from "@/components/extensive/Form/Form"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; -import { useMyOrg } from "@/connections/Organisation"; import { useGetV2FormsUUID, useGetV2ProjectPitches, usePostV2FormsSubmissions } from "@/generated/apiComponents"; import { FormRead } from "@/generated/apiSchemas"; import { useDate } from "@/hooks/useDate"; +import { useMyOrg } from "@/hooks/useMyOrg"; const schema = yup.object({ pitch_uuid: yup.string().required(), @@ -28,10 +28,11 @@ export type FormData = yup.InferType; const FormIntroPage = () => { const t = useT(); const router = useRouter(); + const myOrg = useMyOrg(); const { format } = useDate(); - const [, { organisationId }] = useMyOrg(); const formUUID = router.query.id as string; + const orgUUID = myOrg?.uuid as string; const form = useForm({ resolver: yupResolver(schema) @@ -50,11 +51,11 @@ const FormIntroPage = () => { page: 1, per_page: 10000, //@ts-ignore - "filter[organisation_id]": organisationId + "filter[organisation_id]": orgUUID } }, { - enabled: !!organisationId + enabled: !!orgUUID } ); diff --git a/src/pages/home.page.tsx b/src/pages/home.page.tsx index 252fabf3c..73df2e65c 100644 --- a/src/pages/home.page.tsx +++ b/src/pages/home.page.tsx @@ -14,14 +14,14 @@ import TaskList from "@/components/extensive/TaskList/TaskList"; import { useGetHomeTourItems } from "@/components/extensive/WelcomeTour/useGetHomeTourItems"; import WelcomeTour from "@/components/extensive/WelcomeTour/WelcomeTour"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; -import { useMyOrg } from "@/connections/Organisation"; import { useGetV2FundingProgramme } from "@/generated/apiComponents"; import { useAcceptInvitation } from "@/hooks/useInviteToken"; +import { useMyOrg } from "@/hooks/useMyOrg"; import { fundingProgrammeToFundingCardProps } from "@/utils/dataTransformation"; const HomePage = () => { const t = useT(); - const [, { organisation, organisationId }] = useMyOrg(); + const myOrg = useMyOrg(); const route = useRouter(); const tourSteps = useGetHomeTourItems(); useAcceptInvitation(); @@ -47,7 +47,7 @@ const HomePage = () => { - + { } /> - + + + - + Funding Opportunities`)} @@ -73,7 +75,7 @@ const HomePage = () => { title: t("Organizational Information"), subtitle: t("Keep your profile updated to have more chances of having a successful application. "), actionText: t("View"), - actionUrl: `/organization/${organisationId}`, + actionUrl: `/organization/${myOrg?.uuid}`, iconProps: { name: IconNames.BRANCH_CIRCLE, className: "fill-success" @@ -85,7 +87,7 @@ const HomePage = () => { 'Start a pitch or edit your pitches to apply for funding opportunities. To go to create a pitch, manage your pitches/funding applications, tap on "view".' ), actionText: t("View"), - actionUrl: `/organization/${organisationId}?tab=pitches`, + actionUrl: `/organization/${myOrg?.uuid}?tab=pitches`, iconProps: { name: IconNames.LIGHT_BULB_CIRCLE, className: "fill-success" diff --git a/src/pages/my-projects/index.page.tsx b/src/pages/my-projects/index.page.tsx index 02fbc5ac8..1dc514c19 100644 --- a/src/pages/my-projects/index.page.tsx +++ b/src/pages/my-projects/index.page.tsx @@ -15,13 +15,13 @@ import PageFooter from "@/components/extensive/PageElements/Footer/PageFooter"; import PageHeader from "@/components/extensive/PageElements/Header/PageHeader"; import PageSection from "@/components/extensive/PageElements/Section/PageSection"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; -import { useMyOrg } from "@/connections/Organisation"; import { ToastType, useToastContext } from "@/context/toast.provider"; import { GetV2MyProjectsResponse, useDeleteV2ProjectsUUID, useGetV2MyProjects } from "@/generated/apiComponents"; +import { useMyOrg } from "@/hooks/useMyOrg"; const MyProjectsPage = () => { const t = useT(); - const [, { organisation }] = useMyOrg(); + const myOrg = useMyOrg(); const { openToast } = useToastContext(); const { data: projectsData, isLoading, refetch } = useGetV2MyProjects<{ data: GetV2MyProjectsResponse }>({}); @@ -51,7 +51,7 @@ const MyProjectsPage = () => { - + 0}> diff --git a/src/pages/opportunities/index.page.tsx b/src/pages/opportunities/index.page.tsx index 6b3fa5dc4..9b80013fb 100644 --- a/src/pages/opportunities/index.page.tsx +++ b/src/pages/opportunities/index.page.tsx @@ -18,14 +18,14 @@ import PageSection from "@/components/extensive/PageElements/Section/PageSection import ApplicationsTable from "@/components/extensive/Tables/ApplicationsTable"; import PitchesTable from "@/components/extensive/Tables/PitchesTable"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; -import { useMyOrg } from "@/connections/Organisation"; import { useGetV2FundingProgramme, useGetV2MyApplications } from "@/generated/apiComponents"; +import { useMyOrg } from "@/hooks/useMyOrg"; import { fundingProgrammeToFundingCardProps } from "@/utils/dataTransformation"; const OpportunitiesPage = () => { const t = useT(); const route = useRouter(); - const [, { organisation, organisationId }] = useMyOrg(); + const myOrg = useMyOrg(); const [pitchesCount, setPitchesCount] = useState(); const { data: fundingProgrammes, isLoading: loadingFundingProgrammes } = useGetV2FundingProgramme({ @@ -50,7 +50,7 @@ const OpportunitiesPage = () => { - + @@ -104,15 +104,13 @@ const OpportunitiesPage = () => { "You can use pitches to apply for funding opportunities. By creating a pitch, you will have a ready-to-use resource that can be used to submit applications when funding opportunities are announced." )} headerChildren={ - } > - setPitchesCount((data.meta as any)?.total)} - /> + {/* @ts-ignore missing total field in docs */} + setPitchesCount(data.meta?.total)} /> @@ -128,7 +126,7 @@ const OpportunitiesPage = () => { iconProps={{ name: IconNames.LIGHT_BULB_CIRCLE, className: "fill-success" }} ctaProps={{ as: Link, - href: `/organization/${organisationId}/project-pitch/create/intro`, + href: `/organization/${myOrg?.uuid}/project-pitch/create/intro`, children: t("Create Pitch") }} /> diff --git a/src/pages/organization/create/index.page.tsx b/src/pages/organization/create/index.page.tsx index 1b3573abc..337c37554 100644 --- a/src/pages/organization/create/index.page.tsx +++ b/src/pages/organization/create/index.page.tsx @@ -7,7 +7,6 @@ import { ModalId } from "@/components/extensive/Modal/ModalConst"; import WizardForm from "@/components/extensive/WizardForm"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; -import { useMyOrg } from "@/connections/Organisation"; import { useModalContext } from "@/context/modal.provider"; import { useDeleteV2OrganisationsRetractMyDraft, @@ -17,17 +16,18 @@ import { } from "@/generated/apiComponents"; import { V2OrganisationRead } from "@/generated/apiSchemas"; import { useNormalizedFormDefaultValue } from "@/hooks/useGetCustomFormSteps/useGetCustomFormSteps"; +import { useMyOrg } from "@/hooks/useMyOrg"; import { getSteps } from "./getCreateOrganisationSteps"; const CreateOrganisationForm = () => { const t = useT(); const router = useRouter(); - const [, { organisationId }] = useMyOrg(); + const myOrg = useMyOrg(); const { openModal, closeModal } = useModalContext(); const queryClient = useQueryClient(); - const uuid = (organisationId || router?.query?.uuid) as string; + const uuid = (myOrg?.uuid || router?.query?.uuid) as string; const { mutate: updateOrganisation, isLoading, isSuccess } = usePutV2OrganisationsUUID({}); diff --git a/src/pages/organization/status/pending.page.tsx b/src/pages/organization/status/pending.page.tsx index 145424abb..ebe6cc5be 100644 --- a/src/pages/organization/status/pending.page.tsx +++ b/src/pages/organization/status/pending.page.tsx @@ -5,11 +5,11 @@ import HandsPlantingImage from "public/images/hands-planting.webp"; import Text from "@/components/elements/Text/Text"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; -import { useMyOrg } from "@/connections/Organisation"; +import { useMyOrg } from "@/hooks/useMyOrg"; const OrganizationPendingPage = () => { const t = useT(); - const [, { organisation }] = useMyOrg(); + const myOrg = useMyOrg(); return ( @@ -23,7 +23,7 @@ const OrganizationPendingPage = () => { {t( "You'll receive an email confirmation when your request has been approved. Ask a member of your organization ({organizationName}) to approve your request.", - { organizationName: organisation?.name } + { organizationName: myOrg?.name } )}
diff --git a/src/pages/organization/status/rejected.page.tsx b/src/pages/organization/status/rejected.page.tsx index 3203ef1b7..e992b80e5 100644 --- a/src/pages/organization/status/rejected.page.tsx +++ b/src/pages/organization/status/rejected.page.tsx @@ -6,11 +6,11 @@ import Text from "@/components/elements/Text/Text"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; -import { useMyOrg } from "@/connections/Organisation"; import { zendeskSupportLink } from "@/constants/links"; +import { useMyOrg } from "@/hooks/useMyOrg"; const OrganizationRejectedPage = () => { - const [, { organisation }] = useMyOrg(); + const myOrg = useMyOrg(); const t = useT(); return ( @@ -25,7 +25,7 @@ const OrganizationRejectedPage = () => { {t( "Your request to create/join the organization ({ organizationName }) has been rejected. You have been locked out of the platform and your account has been rejected.", - { organizationName: organisation?.name } + { organizationName: myOrg?.name } )}
diff --git a/src/pages/site/[uuid]/tabs/Overview.tsx b/src/pages/site/[uuid]/tabs/Overview.tsx index e1638ead6..5e3ad683a 100644 --- a/src/pages/site/[uuid]/tabs/Overview.tsx +++ b/src/pages/site/[uuid]/tabs/Overview.tsx @@ -40,7 +40,6 @@ import { SitePolygonsDataResponse, SitePolygonsLoadedDataResponse } from "@/gene import { getEntityDetailPageLink } from "@/helpers/entity"; import { statusActionsMap } from "@/hooks/AuditStatus/useAuditLogActions"; import { FileType, UploadedFile } from "@/types/common"; -import Log from "@/utils/log"; import SiteArea from "../components/SiteArea"; @@ -165,7 +164,7 @@ const SiteOverviewTab = ({ site, refetch: refetchEntity }: SiteOverviewTabProps) setSubmitPolygonLoaded(false); } catch (error) { if (error && typeof error === "object" && "message" in error) { - let errorMessage = (error as { message: string }).message; + let errorMessage = error.message as string; const parsedMessage = JSON.parse(errorMessage); if (parsedMessage && typeof parsedMessage === "object" && "message" in parsedMessage) { errorMessage = parsedMessage.message; @@ -349,7 +348,7 @@ const SiteOverviewTab = ({ site, refetch: refetchEntity }: SiteOverviewTabProps) setShouldRefetchPolygonData(true); openNotification("success", t("Success! Your polygons were submitted.")); } catch (error) { - Log.error("Failed to fetch polygon statuses", error); + console.log(error); } }} /> diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts deleted file mode 100644 index fa6a2ced8..000000000 --- a/src/store/apiSlice.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { createListenerMiddleware, createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { WritableDraft } from "immer"; -import { isArray } from "lodash"; -import { HYDRATE } from "next-redux-wrapper"; -import { Store } from "redux"; - -import { setAccessToken } from "@/admin/apiProvider/utils/token"; -import { LoginDto, OrganisationDto, UserDto } from "@/generated/v3/userService/userServiceSchemas"; - -export type PendingErrorState = { - statusCode: number; - message: string; - error?: string; -}; - -export type Pending = true | PendingErrorState; - -export const isInProgress = (pending?: Pending) => pending === true; - -export const isErrorState = (pending?: Pending): pending is PendingErrorState => - pending != null && !isInProgress(pending); - -const METHODS = ["GET", "DELETE", "POST", "PUT", "PATCH"] as const; -export type Method = (typeof METHODS)[number]; - -export type ApiPendingStore = { - [key in Method]: Record; -}; - -type AttributeValue = string | number | boolean; -type Attributes = { - [key: string]: AttributeValue | Attributes; -}; - -type Relationship = { - type: string; - id: string; - meta?: Attributes; -}; - -export type Relationships = { - [key: string]: Relationship[]; -}; - -export type StoreResource = { - attributes: AttributeType; - // We do a bit of munging on the shape from the API, removing the intermediate "data" member, and - // ensuring there's always an array, to make consuming the data clientside a little smoother. - relationships?: Relationships; -}; - -type StoreResourceMap = Record>; - -// The list of potential resource types. IMPORTANT: When a new resource type is integrated, it must -// be added to this list. -export const RESOURCES = ["logins", "organisations", "users"] as const; - -type ApiResources = { - logins: StoreResourceMap; - organisations: StoreResourceMap; - users: StoreResourceMap; -}; - -export type JsonApiResource = { - type: (typeof RESOURCES)[number]; - id: string; - attributes: Attributes; - relationships?: { [key: string]: { data: Relationship | Relationship[] } }; -}; - -export type JsonApiResponse = { - data: JsonApiResource[] | JsonApiResource; - included?: JsonApiResource[]; -}; - -export type ApiDataStore = ApiResources & { - meta: { - /** Stores the state of in-flight and failed requests */ - pending: ApiPendingStore; - - /** Is snatched and stored by middleware when a users/me request completes. */ - meUserId?: string; - }; -}; - -export const INITIAL_STATE = { - ...RESOURCES.reduce((acc: Partial, resource) => { - acc[resource] = {}; - return acc; - }, {}), - - meta: { - pending: METHODS.reduce((acc: Partial, method) => { - acc[method] = {}; - return acc; - }, {}) as ApiPendingStore - } -} as ApiDataStore; - -type ApiFetchStartingProps = { - url: string; - method: Method; -}; -type ApiFetchFailedProps = ApiFetchStartingProps & { - error: PendingErrorState; -}; -type ApiFetchSucceededProps = ApiFetchStartingProps & { - response: JsonApiResponse; -}; - -const clearApiCache = (state: WritableDraft) => { - for (const resource of RESOURCES) { - state[resource] = {}; - } - - for (const method of METHODS) { - state.meta.pending[method] = {}; - } - - delete state.meta.meUserId; -}; - -const isLogin = ({ url, method }: { url: string; method: Method }) => - url.endsWith("auth/v3/logins") && method === "POST"; - -export const apiSlice = createSlice({ - name: "api", - - initialState: INITIAL_STATE, - - reducers: { - apiFetchStarting: (state, action: PayloadAction) => { - const { url, method } = action.payload; - state.meta.pending[method][url] = true; - }, - apiFetchFailed: (state, action: PayloadAction) => { - const { url, method, error } = action.payload; - state.meta.pending[method][url] = error; - }, - apiFetchSucceeded: (state, action: PayloadAction) => { - const { url, method, response } = action.payload; - if (isLogin(action.payload)) { - // After a successful login, clear the entire cache; we want all mounted components to - // re-fetch their data with the new login credentials. - clearApiCache(state); - } else { - delete state.meta.pending[method][url]; - } - - // All response objects from the v3 api conform to JsonApiResponse - let { data, included } = response; - if (!isArray(data)) data = [data]; - if (included != null) { - // For the purposes of this reducer, data and included are the same: they both get merged - // into the data cache. - data = [...data, ...included]; - } - for (const resource of data) { - // The data resource type is expected to match what is declared above in ApiDataStore, but - // there isn't a way to enforce that with TS against this dynamic data structure, so we - // use the dreaded any. - const { type, id, attributes, relationships: responseRelationships } = resource; - const storeResource: StoreResource = { attributes }; - if (responseRelationships != null) { - storeResource.relationships = {}; - for (const [key, { data }] of Object.entries(responseRelationships)) { - storeResource.relationships[key] = Array.isArray(data) ? data : [data]; - } - } - state[type][id] = storeResource; - } - - if (url.endsWith("users/v3/users/me") && method === "GET") { - state.meta.meUserId = (response.data as JsonApiResource).id; - } - }, - - clearApiCache, - - // only used during app bootup. - setInitialAuthToken: (state, action: PayloadAction<{ authToken: string }>) => { - const { authToken } = action.payload; - // We only ever expect there to be at most one Login in the store, and we never inspect the ID - // so we can safely fake a login into the store when we have an authToken already set in a - // cookie on app bootup. - state.logins["1"] = { attributes: { token: authToken } }; - } - }, - - extraReducers: builder => { - builder.addCase(HYDRATE, (state, action) => { - const { - payload: { api: payloadState } - } = action as unknown as PayloadAction<{ api: ApiDataStore }>; - - if (state.meta.meUserId !== payloadState.meta.meUserId) { - // It's likely the server hasn't loaded as many resources as the client. We should only - // clear out our cached client-side state if the server claims to have a different logged-in - // user state than we do. - clearApiCache(state); - } - - for (const resource of RESOURCES) { - state[resource] = payloadState[resource] as StoreResourceMap; - } - - for (const method of METHODS) { - state.meta.pending[method] = payloadState.meta.pending[method]; - } - - if (payloadState.meta.meUserId != null) { - state.meta.meUserId = payloadState.meta.meUserId; - } - }); - } -}); - -export const authListenerMiddleware = createListenerMiddleware(); -authListenerMiddleware.startListening({ - actionCreator: apiSlice.actions.apiFetchSucceeded, - effect: async ( - action: PayloadAction<{ - url: string; - method: Method; - response: JsonApiResponse; - }> - ) => { - if (!isLogin(action.payload)) return; - const { token } = (action.payload.response.data as JsonApiResource).attributes as LoginDto; - setAccessToken(token); - } -}); - -export default class ApiSlice { - private static _redux: Store; - - static set redux(store: Store) { - this._redux = store; - } - - static get redux(): Store { - return this._redux; - } - - static get apiDataStore(): ApiDataStore { - return this.redux.getState().api; - } - - static fetchStarting(props: ApiFetchStartingProps) { - this.redux.dispatch(apiSlice.actions.apiFetchStarting(props)); - } - - static fetchFailed(props: ApiFetchFailedProps) { - this.redux.dispatch(apiSlice.actions.apiFetchFailed(props)); - } - - static fetchSucceeded(props: ApiFetchSucceededProps) { - this.redux.dispatch(apiSlice.actions.apiFetchSucceeded(props)); - } - - static clearApiCache() { - this.redux.dispatch(apiSlice.actions.clearApiCache()); - } -} diff --git a/src/store/store.ts b/src/store/store.ts deleted file mode 100644 index 4b9679305..000000000 --- a/src/store/store.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { configureStore } from "@reduxjs/toolkit"; -import { createWrapper, MakeStore } from "next-redux-wrapper"; -import { Store } from "redux"; -import { createLogger } from "redux-logger"; - -import ApiSlice, { ApiDataStore, apiSlice, authListenerMiddleware } from "@/store/apiSlice"; - -export type AppStore = { - api: ApiDataStore; -}; - -export const makeStore = () => { - const store = configureStore({ - reducer: { - api: apiSlice.reducer - }, - middleware: getDefaultMiddleware => { - const includeLogger = - typeof window !== "undefined" && process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test"; - - if (includeLogger) { - // Most of our actions include a URL, and it's useful to have that in the top level visible - // log when it's present. - const logger = createLogger({ - titleFormatter: (action: any, time: string, took: number) => { - const extra = action?.payload?.url == null ? "" : ` [${action.payload.url}]`; - return `action @ ${time} ${action.type} (in ${took.toFixed(2)} ms)${extra}`; - } - }); - return getDefaultMiddleware().prepend(authListenerMiddleware.middleware).concat(logger); - } else { - return getDefaultMiddleware().prepend(authListenerMiddleware.middleware); - } - } - }); - - ApiSlice.redux = store; - - return store; -}; - -export const wrapper = createWrapper>(makeStore as MakeStore>); diff --git a/src/types/connection.ts b/src/types/connection.ts deleted file mode 100644 index 3fc05d7c8..000000000 --- a/src/types/connection.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ApiDataStore } from "@/store/apiSlice"; - -export type OptionalProps = Record | undefined; - -export type Selector = ( - state: ApiDataStore, - props: PropsType -) => SelectedType; - -export type Connection = { - selector: Selector; - isLoaded?: (selected: SelectedType, props: PropsType) => boolean; - load?: (selected: SelectedType, props: PropsType) => void; -}; - -export type Connected = readonly [true, SelectedType] | readonly [false, Record]; diff --git a/src/utils/connectionShortcuts.ts b/src/utils/connectionShortcuts.ts deleted file mode 100644 index 49366a9bd..000000000 --- a/src/utils/connectionShortcuts.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useConnection } from "@/hooks/useConnection"; -import ApiSlice from "@/store/apiSlice"; -import { Connection, OptionalProps } from "@/types/connection"; -import { loadConnection } from "@/utils/loadConnection"; - -/** - * Generates a hook for using this specific connection. - */ -export function connectionHook(connection: Connection) { - return (props: TProps | Record = {}) => useConnection(connection, props); -} - -/** - * Generates an async loader for this specific connection. Awaiting on the loader will not return - * until the connection is in a valid loaded state. - */ -export function connectionLoader(connection: Connection) { - return (props: TProps | Record = {}) => loadConnection(connection, props); -} - -/** - * Generates a synchronous selector for this specific connection. Ignores loaded state and simply - * returns the current connection state with whatever is currently cached in the store. - * - * Note: Use sparingly! There are very few cases where this type of connection access is actually - * desirable. In almost every case, connectionHook or connectionLoader is what you really want. - */ -export function connectionSelector(connection: Connection) { - return (props: TProps | Record = {}) => connection.selector(ApiSlice.apiDataStore, props); -} diff --git a/src/utils/geojson.ts b/src/utils/geojson.ts index 8cf2035d8..d4060f25d 100644 --- a/src/utils/geojson.ts +++ b/src/utils/geojson.ts @@ -16,7 +16,7 @@ import normalize from "@mapbox/geojson-normalize"; * { type: 'Feature', geometry: { type: 'Point', coordinates: [0, 1] }, properties: {} } * ]); * - * Log.debug(JSON.stringify(mergedGeoJSON)); + * console.log(JSON.stringify(mergedGeoJSON)); */ export const merge = (inputs: any) => { var output: any = { diff --git a/src/utils/loadConnection.ts b/src/utils/loadConnection.ts deleted file mode 100644 index 08870d80f..000000000 --- a/src/utils/loadConnection.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Unsubscribe } from "redux"; - -import ApiSlice, { ApiDataStore } from "@/store/apiSlice"; -import { Connection, OptionalProps } from "@/types/connection"; - -export async function loadConnection( - connection: Connection, - props: PType | Record = {} -) { - const { selector, isLoaded, load } = connection; - const predicate = (store: ApiDataStore) => { - const connected = selector(store, props); - const loaded = isLoaded == null || isLoaded(connected, props); - if (!loaded && load != null) load(connected, props); - return loaded; - }; - - const store = ApiSlice.apiDataStore; - if (predicate(store)) return selector(store, props); - - const unsubscribe = await new Promise(resolve => { - const unsubscribe = ApiSlice.redux.subscribe(() => { - if (predicate(ApiSlice.apiDataStore)) resolve(unsubscribe); - }); - }); - unsubscribe(); - - return selector(ApiSlice.apiDataStore, props); -} diff --git a/src/utils/log.ts b/src/utils/log.ts deleted file mode 100644 index 8375c89ef..000000000 --- a/src/utils/log.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { captureException, captureMessage, SeverityLevel, withScope } from "@sentry/nextjs"; - -const IS_PROD = process.env.NODE_ENV === "production"; - -const sentryLog = (level: SeverityLevel, message: any, optionalParams: any[]) => { - const error = optionalParams.find(param => param instanceof Error); - - withScope(scope => { - if (error == null) { - scope.setExtras({ optionalParams }); - captureMessage(message, level); - } else { - scope.setExtras({ message, optionalParams }); - captureException(error); - } - }); -}; - -export default class Log { - static debug(message: any, ...optionalParams: any[]) { - if (!IS_PROD) console.debug(message, ...optionalParams); - } - - static info(message: any, ...optionalParams: any[]) { - if (!IS_PROD) console.info(message, ...optionalParams); - } - - static warn(message: any, ...optionalParams: any[]) { - console.warn(message, ...optionalParams); - sentryLog("warning", message, optionalParams); - } - - static error(message: any, ...optionalParams: any[]) { - console.error(message, ...optionalParams); - sentryLog("error", message, optionalParams); - } -} diff --git a/src/utils/network.ts b/src/utils/network.ts index d200ab381..e93a9c366 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -2,8 +2,6 @@ import { QueryClient } from "@tanstack/react-query"; import { GetServerSidePropsContext } from "next"; import nookies from "nookies"; -import Log from "@/utils/log"; - /** * Prefetch queries in ServerSideProps * @param queryClient Tanstack QueryClient @@ -34,7 +32,7 @@ export const downloadFile = async (fileUrl: string) => { const blob = await res.blob(); downloadFileBlob(blob, fileName); } catch (err) { - Log.error("Failed to download file", fileUrl, err); + console.log(err); } }; @@ -55,6 +53,6 @@ export const downloadFileBlob = async (blob: Blob, fileName: string) => { // Clean up and remove the link link?.parentNode?.removeChild(link); } catch (err) { - Log.error("Failed to download blob", fileName, err); + console.log(err); } }; diff --git a/src/utils/selectorCache.ts b/src/utils/selectorCache.ts deleted file mode 100644 index 8ea7a3b3b..000000000 --- a/src/utils/selectorCache.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ApiDataStore } from "@/store/apiSlice"; -import { Selector } from "@/types/connection"; - -type PureSelector = (store: ApiDataStore) => S; - -/** - * A factory and cache pattern for creating pure selectors from the ApiDataStore. This allows - * a connection that takes a given set of props, and is likely to get called many times during the - * lifecycle of the component to ensure that its selectors aren't getting re-created on every - * render, and are therefore going to get the performance gains we want from reselect. - * - * @param keyFactory A method that returns a string representation of the hooks props - * @param selectorFactory A method that returns a pure (store-only) selector. - */ -export function selectorCache>( - keyFactory: (props: P) => string, - selectorFactory: (props: P) => PureSelector -): Selector { - const selectors = new Map>(); - - return (store: ApiDataStore, props: P) => { - const key = keyFactory(props); - let selector = selectors.get(key); - if (selector == null) { - selectors.set(key, (selector = selectorFactory(props))); - } - return selector(store); - }; -} diff --git a/src/utils/testStore.tsx b/src/utils/testStore.tsx deleted file mode 100644 index 553219819..000000000 --- a/src/utils/testStore.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { cloneDeep } from "lodash"; -import { HYDRATE } from "next-redux-wrapper"; -import { ReactNode, useMemo } from "react"; -import { Provider as ReduxProvider } from "react-redux"; - -import { INITIAL_STATE } from "@/store/apiSlice"; -import { makeStore } from "@/store/store"; - -class StoreBuilder { - store = cloneDeep(INITIAL_STATE); - - addLogin(token: string) { - this.store.logins[1] = { attributes: { token } }; - return this; - } -} - -export const StoreProvider = ({ storeBuilder, children }: { storeBuilder?: StoreBuilder; children: ReactNode }) => { - // We avoid using wrapper.useWrappedStore here so that different storybook components on the same page - // can have different instances of the redux store. This is a little wonky because anything that - // uses ApiSlice.store directly is going to get the last store created every time, including anything - // that uses connection loads or selectors from connectionShortcuts. However, storybook stories - // should be providing a store that has everything that component needs already loaded, and the - // components only use useConnection, so this will at least work for the expected normal case. - const store = useMemo( - () => { - const store = makeStore(); - const initialState = storeBuilder == null ? undefined : { api: storeBuilder.store }; - if (initialState != null) { - store.dispatch({ type: HYDRATE, payload: initialState }); - } - - return store; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ); - return {children}; -}; - -export const buildStore = () => new StoreBuilder(); diff --git a/yarn.lock b/yarn.lock index d638fe11d..d7ba39a10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2829,9 +2829,9 @@ typescript "4.8.2" "@openapi-codegen/typescript@^6.1.0": - version "6.2.4" - resolved "https://registry.yarnpkg.com/@openapi-codegen/typescript/-/typescript-6.2.4.tgz#0004c450486f16e76bbef3b278bb32bebdc7aff7" - integrity sha512-wh/J7Ij/furDIYo0yD8SjUZBCHn2+cu7N4cTKJ9M/PW7jaDYHyZk1ThYmtCFAVF2f3Jobpb51+3E0Grk8nqhpA== + version "6.1.0" + resolved "https://registry.yarnpkg.com/@openapi-codegen/typescript/-/typescript-6.1.0.tgz#66850506a89a2a2f24a45db6a7a3ea21357de758" + integrity sha512-zwCw06hhk8BFS4DMOmOCuAFU6rfWql2M4VL7RxaqEsDWopi1GLtEJpKmRNUplv3aGGq/OLdJs1F9VdSitI1W2A== dependencies: case "^1.6.3" lodash "^4.17.21" @@ -2878,16 +2878,6 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== -"@reduxjs/toolkit@^2.2.7": - version "2.2.7" - resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.2.7.tgz#199e3d10ccb39267cb5aee92c0262fd9da7fdfb2" - integrity sha512-faI3cZbSdFb8yv9dhDTmGwclW0vk0z5o1cia+kf7gCbaCwHI5e+7tP57mJUv22pNcNbeA62GSrPpfrUfdXcQ6g== - dependencies: - immer "^10.0.3" - redux "^5.0.1" - redux-thunk "^3.1.0" - reselect "^5.1.0" - "@remirror/core-constants@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@remirror/core-constants/-/core-constants-2.0.2.tgz#f05eccdc69e3a65e7d524b52548f567904a11a1a" @@ -5031,11 +5021,6 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== -"@types/pluralize@^0.0.33": - version "0.0.33" - resolved "https://registry.yarnpkg.com/@types/pluralize/-/pluralize-0.0.33.tgz#8ad9018368c584d268667dd9acd5b3b806e8c82a" - integrity sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg== - "@types/prettier@^2.1.5": version "2.7.2" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.2.tgz#6c2324641cc4ba050a8c710b2b251b377581fbf0" @@ -5126,13 +5111,6 @@ "@types/scheduler" "*" csstype "^3.0.2" -"@types/redux-logger@^3.0.13": - version "3.0.13" - resolved "https://registry.yarnpkg.com/@types/redux-logger/-/redux-logger-3.0.13.tgz#473e98428cdcc6dc93c908de66732bf932e36bc8" - integrity sha512-jylqZXQfMxahkuPcO8J12AKSSCQngdEWQrw7UiLUJzMBcv1r4Qg77P6mjGLjM27e5gFQDPD8vwUMJ9AyVxFSsg== - dependencies: - redux "^5.0.0" - "@types/scheduler@*": version "0.16.2" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" @@ -5188,11 +5166,6 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.8.tgz#bb197b9639aa1a04cf464a617fe800cccd92ad5c" integrity sha512-d0XxK3YTObnWVp6rZuev3c49+j4Lo8g4L1ZRm9z5L0xpoZycUPshHgczK5gsUMaZOstjVYYi09p5gYvUtfChYw== -"@types/use-sync-external-store@^0.0.3": - version "0.0.3" - resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43" - integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== - "@types/uuid@^9.0.8": version "9.0.8" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba" @@ -7372,11 +7345,6 @@ dedent@^0.7.0: resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" integrity sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA== -deep-diff@^0.3.5: - version "0.3.8" - resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-0.3.8.tgz#c01de63efb0eec9798801d40c7e0dae25b582c84" - integrity sha512-yVn6RZmHiGnxRKR9sJb3iVV2XTF1Ghh2DiWRZ3dMnGc43yUdWWF/kX6lQyk3+P84iprfWKU/8zFTrlkvtFm1ug== - deep-equal@^2.0.5: version "2.2.0" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.0.tgz#5caeace9c781028b9ff459f33b779346637c43e6" @@ -9414,11 +9382,6 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== -immer@^10.0.3: - version "10.1.1" - resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc" - integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw== - import-fresh@^3.0.0, import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" @@ -11739,11 +11702,6 @@ neo-async@^2.5.0, neo-async@^2.6.0, neo-async@^2.6.1, neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== -next-redux-wrapper@^8.1.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/next-redux-wrapper/-/next-redux-wrapper-8.1.0.tgz#d9c135f1ceeb2478375bdacd356eb9db273d3a07" - integrity sha512-2hIau0hcI6uQszOtrvAFqgc0NkZegKYhBB7ZAKiG3jk7zfuQb4E7OV9jfxViqqojh3SEHdnFfPkN9KErttUKuw== - next-router-mock@^0.9.3: version "0.9.3" resolved "https://registry.yarnpkg.com/next-router-mock/-/next-router-mock-0.9.3.tgz#8287e96d76d4c7b3720bc9078b148c2b352f1567" @@ -13631,14 +13589,6 @@ react-reconciler@^0.26.2: object-assign "^4.1.1" scheduler "^0.20.2" -react-redux@^9.1.2: - version "9.1.2" - resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.1.2.tgz#deba38c64c3403e9abd0c3fbeab69ffd9d8a7e4b" - integrity sha512-0OA4dhM1W48l3uzmv6B7TXPCGmokUU4p1M44DGN2/D9a1FjVPukVjER1PcPX97jIg6aUeLq1XJo1IpfbgULn0w== - dependencies: - "@types/use-sync-external-store" "^0.0.3" - use-sync-external-store "^1.0.0" - react-refresh@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046" @@ -13830,23 +13780,6 @@ redeyed@~2.1.0: dependencies: esprima "~4.0.0" -redux-logger@^3.0.6: - version "3.0.6" - resolved "https://registry.yarnpkg.com/redux-logger/-/redux-logger-3.0.6.tgz#f7555966f3098f3c88604c449cf0baf5778274bf" - integrity sha512-JoCIok7bg/XpqA1JqCqXFypuqBbQzGQySrhFzewB7ThcnysTO30l4VCst86AuB9T9tuT03MAA56Jw2PNhRSNCg== - dependencies: - deep-diff "^0.3.5" - -redux-thunk@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" - integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== - -redux@^5.0.0, redux@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" - integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== - reftools@^1.1.9: version "1.1.9" resolved "https://registry.yarnpkg.com/reftools/-/reftools-1.1.9.tgz#e16e19f662ccd4648605312c06d34e5da3a2b77e" @@ -14005,11 +13938,6 @@ reselect@^4.1.8: resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.8.tgz#3f5dc671ea168dccdeb3e141236f69f02eaec524" integrity sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ== -reselect@^5.1.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e" - integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w== - resolve-alpn@^1.2.0: version "1.2.1" resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9" @@ -15758,11 +15686,6 @@ use-resize-observer@^9.1.0: dependencies: "@juggle/resize-observer" "^3.3.1" -use-sync-external-store@^1.0.0: - version "1.2.2" - resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9" - integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw== - use-sync-external-store@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" From 87cbb35a04657132a95fc90e09b539d25642f771 Mon Sep 17 00:00:00 2001 From: Dotnara Condori Date: Tue, 22 Oct 2024 18:09:52 -0400 Subject: [PATCH 039/102] [TM-1410] dashboard update tooltip definitions and related items (#585) * [TM-1410] update text tooltips * TM 1410 add tooltip constants for dashboard * TM-1410 change name of tooltip constants * [TM-1410] update text tooltips * TM-1410 change tooltip definitions * [TM-1410] update text tooltips * [TM-1410] update text tooltips --------- Co-authored-by: diego-morales-flores-1996 --- src/pages/dashboard/[id].page.tsx | 78 ++++++++---------- .../dashboard/components/ContentOverview.tsx | 35 ++++---- .../dashboard/components/HeaderDashboard.tsx | 15 ++++ src/pages/dashboard/constants/tooltips.ts | 80 +++++++++++++++++++ src/pages/dashboard/hooks/useDashboardData.ts | 8 +- src/pages/dashboard/index.page.tsx | 70 ++++++++-------- src/pages/dashboard/project/index.page.tsx | 78 ++++++++---------- 7 files changed, 221 insertions(+), 143 deletions(-) create mode 100644 src/pages/dashboard/constants/tooltips.ts diff --git a/src/pages/dashboard/[id].page.tsx b/src/pages/dashboard/[id].page.tsx index 989d24469..75bc39b0a 100644 --- a/src/pages/dashboard/[id].page.tsx +++ b/src/pages/dashboard/[id].page.tsx @@ -12,6 +12,24 @@ import { CountriesProps } from "@/components/generic/Layout/DashboardLayout"; import ContentOverview from "./components/ContentOverview"; import SecDashboard from "./components/SecDashboard"; +import { + ACTIVE_PROJECTS_TOOLTIP, + HECTARES_UNDER_RESTORATION_TOOLTIP, + JOBS_CREATED_BY_AGE_TOOLTIP, + JOBS_CREATED_BY_GENDER_TOOLTIP, + JOBS_CREATED_SECTION_TOOLTIP, + JOBS_CREATED_TOOLTIP, + NEW_FULL_TIME_JOBS_TOOLTIP, + NEW_PART_TIME_JOBS_TOOLTIP, + NUMBER_OF_TREES_PLANTED_BY_YEAR_TOOLTIP, + NUMBER_OF_TREES_PLANTED_TOOLTIP, + TOP_5_PROJECTS_WITH_MOST_PLANTED_TREES_TOOLTIP, + TOTAL_VOLUNTEERS_TOOLTIP, + TREES_PLANTED_TOOLTIP, + TREES_RESTORED_SECTION_TOOLTIP, + VOLUNTEERS_CREATED_BY_AGE_TOOLTIP, + VOLUNTEERS_CREATED_BY_GENDER_TOOLTIP +} from "./constants/tooltips"; import { DATA_ACTIVE_COUNTRY, JOBS_CREATED_BY_AGE, @@ -39,20 +57,17 @@ const Country: React.FC = ({ selectedCountry }) => { { label: "Trees Planted", value: "12.2M", - tooltip: - "Total number of trees planted by funded projects to date, including through assisted natural regeneration, as reported through six-month progress reports." + tooltip: TREES_PLANTED_TOOLTIP }, { label: "Hectares Under Restoration", value: "5,220 ha", - tooltip: - "Total land area measured in hectares with active restoration interventions, tallied by the total area of polygons submitted by projects and approved by data quality analysts." + tooltip: HECTARES_UNDER_RESTORATION_TOOLTIP }, { label: "Jobs Created", value: "23,000", - tooltip: - "Number of jobs created to date. TerraFund defines a job as a set of tasks and duties performed by one person aged 18 or over in exchange for monetary pay in line with living wage standards." + tooltip: JOBS_CREATED_TOOLTIP } ]; @@ -143,9 +158,7 @@ const Country: React.FC = ({ selectedCountry }) => { gap={8} subtitleMore={true} title={t("TREES RESTORED")} - tooltip={t( - "This section displays data related to Indicator 1: Trees Restored described in TerraFund’s Monitoring, Reporting, and Verification framework. Please refer to the linked framework for details on how these numbers are sourced and verified." - )} + tooltip={t(TREES_RESTORED_SECTION_TOOLTIP)} widthTooltip="w-52 lg:w-64" iconClassName="h-3.5 w-3.5 text-darkCustom lg:h-5 lg:w-5" variantSubTitle="text-14-light" @@ -158,9 +171,7 @@ const Country: React.FC = ({ selectedCountry }) => { type="legend" secondOptionsData={LABEL_LEGEND} data={NUMBER_OF_TREES_PLANTED} - tooltip={t( - "Total number of trees that funded projects have planted to date, including through assisted natural regeneration, as reported through 6-month progress reports and displayed as progress towards goal." - )} + tooltip={t(NUMBER_OF_TREES_PLANTED_TOOLTIP)} /> = ({ selectedCountry }) => { secondOptionsData={dataToggle} tooltipGraphic={true} data={NUMBER_OF_TREES_PLANTED_BY_YEAR} - tooltip={t("Number of trees planted in each year.")} + tooltip={t(NUMBER_OF_TREES_PLANTED_BY_YEAR_TOOLTIP)} /> @@ -188,11 +197,10 @@ const Country: React.FC = ({ selectedCountry }) => { title={t("JOBS CREATED")} variantSubTitle="text-14-light" subtitleMore={true} + tooltipTrigger="click" widthTooltip="w-80 lg:w-96" iconClassName="h-3.5 w-3.5 text-darkCustom lg:h-5 lg:w-5" - tooltip={t( - "This section displays data related to Indicator 3: Jobs Created described in TerraFund’s Monitoring, Reporting, and Verification framework. TerraFund defines a job as a set of tasks and duties performed by one person aged 18 years or older in exchange for monetary pay in line with living wage standards. All indicators in the Jobs Created category are disaggregated by number of women, number of men, and number of youths. Restoration Champions are required to report on jobs and volunteers every 6 months and provide additional documentation to verify employment. Please refer to the linked framework for additional details on how these numbers are sourced and verified." - )} + tooltip={t(JOBS_CREATED_SECTION_TOOLTIP)} subtitle={t( `The numbers and reports below display data related to Indicator 3: Jobs Created described in TerraFund’s MRV framework. TerraFund defines a job as a set of tasks and duties performed by one person aged 18 or over in exchange for monetary pay in line with living wage standards. All indicators in the Jobs Created category are disaggregated by number of women, number of men, and number of youths. Restoration Champions are required to report on jobs and volunteers every 6 months and provide additional documentation to verify employment. Please refer to the linked MRV framework for additional details on how these numbers are sourced and verified.` )} @@ -202,18 +210,14 @@ const Country: React.FC = ({ selectedCountry }) => { title={t("New Part-Time Jobs")} data={NEW_PART_TIME_JOBS} classNameBody="w-full place-content-center" - tooltip={t( - "Number of part-time jobs created to date. TerraFund defines a part-time job as under 35 hours per work week." - )} + tooltip={t(NEW_PART_TIME_JOBS_TOOLTIP)} />
@@ -222,41 +226,31 @@ const Country: React.FC = ({ selectedCountry }) => { data={JOBS_CREATED_BY_GENDER} classNameHeader="!justify-center" classNameBody="w-full place-content-center !justify-center flex-col gap-5" - tooltip={t("Total number of jobs created broken down by gender.")} + tooltip={t(JOBS_CREATED_BY_GENDER_TOOLTIP)} />
- +
@@ -266,9 +260,7 @@ const Country: React.FC = ({ selectedCountry }) => { dataTable={DATA_ACTIVE_COUNTRY} columns={COLUMN_ACTIVE_COUNTRY} titleTable={t("ACTIVE PROJECTS")} - textTooltipTable={t( - "For each project, this table shows the number of trees planted, hectares under restoration, jobs created, and volunteers engaged to date. Those with access to individual project pages can click directly on table rows to dive deep." - )} + textTooltipTable={t(ACTIVE_PROJECTS_TOOLTIP)} />
); diff --git a/src/pages/dashboard/components/ContentOverview.tsx b/src/pages/dashboard/components/ContentOverview.tsx index 5949694d7..8aa0d6325 100644 --- a/src/pages/dashboard/components/ContentOverview.tsx +++ b/src/pages/dashboard/components/ContentOverview.tsx @@ -19,6 +19,14 @@ import PageRow from "@/components/extensive/PageElements/Row/PageRow"; import { useModalContext } from "@/context/modal.provider"; import { DashboardGetProjectsData } from "@/generated/apiSchemas"; +import { + HECTARES_UNDER_RESTORATION_SECTION_TOOLTIP, + MAP_TOOLTIP, + RESTORATION_STRATEGIES_REPRESENTED_TOOLTIP, + TARGET_LAND_USE_TYPES_REPRESENTED_TOOLTIP, + TOTAL_HECTARES_UNDER_RESTORATION_TOOLTIP, + TOTAL_NUMBER_OF_SITES_TOOLTIP +} from "../constants/tooltips"; import { RESTORATION_STRATEGIES_REPRESENTED, TARGET_LAND_USE_TYPES_REPRESENTED, @@ -47,12 +55,7 @@ const ContentOverview = (props: ContentOverviewProps) => { const ModalMap = () => { openModal( "modalExpand", - +
@@ -159,9 +162,7 @@ const ContentOverview = (props: ContentOverviewProps) => { subtitleMore={true} title={t("Hectares Under Restoration")} variantSubTitle="text-14-light" - tooltip={t( - "This section displays data related to Indicator 2: Hectares Under Restoration described in TerraFund’s Monitoring, Reporting, and Verification framework. Please refer to the linked framework for details on how these numbers are sourced and verified. Restoration strategies and target land use types are defined here." - )} + tooltip={t(HECTARES_UNDER_RESTORATION_SECTION_TOOLTIP)} iconClassName="h-3.5 w-3.5 text-darkCustom lg:h-5 lg:w-5" subtitle={t( `The numbers and reports below display data related to Indicator 2: Hectares Under Restoration described in TerraFund’s MRV framework. Please refer to the linked MRV framework for details on how these numbers are sourced and verified.` @@ -173,33 +174,25 @@ const ContentOverview = (props: ContentOverviewProps) => { title={t("Total Hectares Under Restoration")} data={TOTAL_HECTARES_UNDER_RESTORATION} classNameBody="w-full place-content-center" - tooltip={t( - "Total land area measured in hectares with active restoration interventions, tallied by the total area of polygons submitted by projects." - )} + tooltip={t(TOTAL_HECTARES_UNDER_RESTORATION_TOOLTIP)} />
diff --git a/src/pages/dashboard/components/HeaderDashboard.tsx b/src/pages/dashboard/components/HeaderDashboard.tsx index 90d636eab..5585f25e6 100644 --- a/src/pages/dashboard/components/HeaderDashboard.tsx +++ b/src/pages/dashboard/components/HeaderDashboard.tsx @@ -10,11 +10,14 @@ import { MENU_ITEM_VARIANT_SEARCH } from "@/components/elements/MenuItem/MenuIte import FilterSearchBox from "@/components/elements/TableFilters/Inputs/FilterSearchBox"; import { FILTER_SEARCH_BOX_AIRTABLE } from "@/components/elements/TableFilters/Inputs/FilterSearchBoxVariants"; import Text from "@/components/elements/Text/Text"; +import ToolTip from "@/components/elements/Tooltip/Tooltip"; +import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import { CountriesProps } from "@/components/generic/Layout/DashboardLayout"; import { useDashboardContext } from "@/context/dashboard.provider"; import { useGetV2DashboardFrameworks } from "@/generated/apiComponents"; import { Option, OptionValue } from "@/types/common"; +import { PROJECT_INSIGHTS_SECTION_TOOLTIP } from "../constants/tooltips"; import { useDashboardData } from "../hooks/useDashboardData"; import BlurContainer from "./BlurContainer"; @@ -183,6 +186,18 @@ const HeaderDashboard = (props: HeaderDashboardProps) => {
{t(getHeaderTitle())} + + + + +
diff --git a/src/pages/dashboard/constants/tooltips.ts b/src/pages/dashboard/constants/tooltips.ts new file mode 100644 index 000000000..8feb4391f --- /dev/null +++ b/src/pages/dashboard/constants/tooltips.ts @@ -0,0 +1,80 @@ +export const TREES_PLANTED_TOOLTIP = + "Total number of trees planted by funded projects to date, as reported through six-month progress reports. This also includes trees planted by projects as part of their assisted natural regeneration activities."; + +export const HECTARES_UNDER_RESTORATION_TOOLTIP = + "Total land area measured in hectares with active restoration interventions, tallied by the total area of polygons submitted by projects and approved by data quality analysts."; + +export const JOBS_CREATED_TOOLTIP = + "Number of people newly employed directly by the project. Terrafund defines a job as any individual or person, aged 18 years or older, that is directly compensated by a project at any time to support their restoration activities."; + +export const NUMBER_OF_TREES_PLANTED_TOOLTIP = + "Total number of trees that funded projects have planted to date, as reported through 6-month progress reports and displayed as progress towards goal. It also includes trees planted as part of assisted natural regeneration activities."; + +export const NUMBER_OF_TREES_PLANTED_BY_YEAR_TOOLTIP = "Number of trees planted in each year."; + +export const TOP_5_PROJECTS_WITH_MOST_PLANTED_TREES_TOOLTIP = + "The 5 projects that have planted the most trees and the corresponding number of trees planted per project. Please note that organization names are listed instead of project names for ease of reference."; + +export const TOP_20_TREE_SPECIES_PLANTED_TOOLTIP = ""; + +export const NEW_PART_TIME_JOBS_TOOLTIP = + "Number of people working part-time jobs to date. Terrafund defines a part-time job as a person working regularly, paid for work on the project but working under 35 hours per work week. Part-time includes all employees engaged on a temporary, casual, or seasonal basis."; + +export const NEW_FULL_TIME_JOBS_TOOLTIP = + "Number of full-time jobs created to date. TerraFund defines a full-time employee as people that are regularly paid for their work on the project and are working more than 35 hours per week throughout the year."; + +export const JOBS_CREATED_BY_GENDER_TOOLTIP = "Total number of employees broken down by gender."; + +export const JOBS_CREATED_BY_AGE_TOOLTIP = + "Total number of employees broken down by age group. Youth is defined as 18-35 years old. Non-youth is defined as older than 35 years old."; + +export const TOTAL_VOLUNTEERS_TOOLTIP = + "Number of unpaid volunteers contributing to the project. A volunteer is an individual that freely dedicates their time to the project because they see value in doing so but does not receive payment for their work."; + +export const VOLUNTEERS_CREATED_BY_GENDER_TOOLTIP = "Total number of volunteers broken down by gender."; + +export const VOLUNTEERS_CREATED_BY_AGE_TOOLTIP = + "Total number of volunteers broken down by age group. Youth is defined as 18-35 years old. Non-youth is defined as older than 35 years old."; + +export const MAP_TOOLTIP = + "Click on a country or project to view additional information. Zooming in on the map will display satellite imagery. Those with access to individual project pages can see approved polygons and photos."; + +export const TOTAL_HECTARES_UNDER_RESTORATION_TOOLTIP = + "Total land area measured in hectares with active restoration interventions, tallied by the total area of polygons submitted by projects."; + +export const TOTAL_NUMBER_OF_SITES_TOOLTIP = + "Sites are the fundamental unit for reporting data on TerraMatch. They consist of either a single restoration area or a grouping of restoration areas, represented by one or several geospatial polygons."; + +export const RESTORATION_STRATEGIES_REPRESENTED_TOOLTIP = + "Total hectares under restoration broken down by restoration strategy. Please note that multiple restoration strategies can occur within a single hectare."; + +export const TARGET_LAND_USE_TYPES_REPRESENTED_TOOLTIP = + "Total hectares under restoration broken down by target land use types."; + +export const ACTIVE_COUNTRIES_TOOLTIP = + "For each country, this table shows the number of projects, trees planted, hectares under restoration, and jobs created to date."; + +export const ACTIVE_PROJECTS_TOOLTIP = + "For each project, this table shows the number of trees planted, hectares under restoration, jobs created, and volunteers engaged to date. Those with access to individual project pages can click directly on table rows to dive deep."; + +export const TREES_RESTORED_SECTION_TOOLTIP = + "This section displays data related to Indicator 1: Trees Restored described in TerraFund’s Monitoring, Reporting, and Verification framework. Please refer to the linked framework for details on how these numbers are sourced and verified."; + +export const HECTARES_UNDER_RESTORATION_SECTION_TOOLTIP = + "This section displays data related to Indicator 2: Hectares Under Restoration described in TerraFund’s Monitoring, Reporting, and Verification framework. Please refer to the linked framework for details on how these numbers are sourced and verified. Restoration strategies and target land use types are defined here."; + +export const JOBS_CREATED_SECTION_TOOLTIP = `This section displays data related to Indicator 3: Jobs Created described in TerraFund’s Monitoring, Reporting, and Verification framework..TerraFund defines a job as any individual or person, aged 18 years or older, that is directly compensated by funded project at any time to support their restoration activities. Jobs created aggregates the number of people who have been newly, directly employed by the project during each 6-month reporting period. + + All indicators in the Jobs Created category are disaggregated by number of women, number of men, and number of youths. Restoration Champions are required to report on jobs and volunteers every 6 months and provide additional documentation to verify employment. Please refer to the linked framework for additional details on how these numbers are sourced and verified. `; + +export const IMPACT_STORIES_TOOLTIP_TOOLTIP = + "Short project success stories will be accessible by early 2025 through the relevant project pages."; + +export const PROJECT_INSIGHTS_SECTION_TOOLTIP = + "In 2025, the Project Insights section will contain additional analyses showing trends and insights."; + +export const NO_DATA_PRESENT_ACTIVE_PROJECT_TOOLTIPS = + "Data is still being collected and checked. This visual will remain empty until data is properly quality assured."; + +export const PERMISSIONS_REQUIRED_TOOLTIP = + "To ensure the protection of sensitive location data, this section is not accessible without the proper permissions. Click here to log in to TerraMatch."; diff --git a/src/pages/dashboard/hooks/useDashboardData.ts b/src/pages/dashboard/hooks/useDashboardData.ts index a0f0d7a61..826f8f33e 100644 --- a/src/pages/dashboard/hooks/useDashboardData.ts +++ b/src/pages/dashboard/hooks/useDashboardData.ts @@ -17,23 +17,25 @@ import { import { DashboardTreeRestorationGoalResponse } from "@/generated/apiSchemas"; import { createQueryParams } from "@/utils/dashboardUtils"; +import { HECTARES_UNDER_RESTORATION_TOOLTIP, JOBS_CREATED_TOOLTIP, TREES_PLANTED_TOOLTIP } from "../constants/tooltips"; + export const useDashboardData = (filters: any) => { const [topProject, setTopProjects] = useState([]); const [dashboardHeader, setDashboardHeader] = useState([ { label: "Trees Planted", value: "0", - tooltip: "Total number of trees planted by funded projects to date." + tooltip: TREES_PLANTED_TOOLTIP }, { label: "Hectares Under Restoration", value: "0 ha", - tooltip: "Total land area with active restoration interventions." + tooltip: HECTARES_UNDER_RESTORATION_TOOLTIP }, { label: "Jobs Created", value: "0", - tooltip: "Number of jobs created to date." + tooltip: JOBS_CREATED_TOOLTIP } ]); const projectUuid = filters.project?.project_uuid; diff --git a/src/pages/dashboard/index.page.tsx b/src/pages/dashboard/index.page.tsx index 29a33c992..85a23b269 100644 --- a/src/pages/dashboard/index.page.tsx +++ b/src/pages/dashboard/index.page.tsx @@ -13,6 +13,25 @@ import { formatLabelsVolunteers } from "@/utils/dashboardUtils"; import ContentOverview from "./components/ContentOverview"; import SecDashboard from "./components/SecDashboard"; +import { + ACTIVE_COUNTRIES_TOOLTIP, + ACTIVE_PROJECTS_TOOLTIP, + JOBS_CREATED_BY_AGE_TOOLTIP, + JOBS_CREATED_BY_GENDER_TOOLTIP, + NEW_FULL_TIME_JOBS_TOOLTIP, + NEW_PART_TIME_JOBS_TOOLTIP, + NUMBER_OF_TREES_PLANTED_BY_YEAR_TOOLTIP, + TOP_5_PROJECTS_WITH_MOST_PLANTED_TREES_TOOLTIP, + TOTAL_VOLUNTEERS_TOOLTIP, + TREES_RESTORED_SECTION_TOOLTIP, + VOLUNTEERS_CREATED_BY_AGE_TOOLTIP, + VOLUNTEERS_CREATED_BY_GENDER_TOOLTIP +} from "./constants/tooltips"; +import { + JOBS_CREATED_SECTION_TOOLTIP, + NO_DATA_PRESENT_ACTIVE_PROJECT_TOOLTIPS, + NUMBER_OF_TREES_PLANTED_TOOLTIP +} from "./constants/tooltips"; import { useDashboardData } from "./hooks/useDashboardData"; import { LABEL_LEGEND } from "./mockedData/dashboard"; @@ -207,7 +226,7 @@ const Dashboard = () => { }; return ( -
+
@@ -252,9 +271,7 @@ const Dashboard = () => { gap={8} subtitleMore={true} title={t("Trees Restored")} - tooltip={t( - "This section displays data related to Indicator 1: Trees Restored described in TerraFund’s Monitoring, Reporting, and Verification framework. Please refer to the linked framework for details on how these numbers are sourced and verified." - )} + tooltip={t(TREES_RESTORED_SECTION_TOOLTIP)} widthTooltip="w-52 lg:w-64" iconClassName="h-3.5 w-3.5 text-darkCustom lg:h-5 lg:w-5" variantSubTitle="text-14-light" @@ -266,9 +283,7 @@ const Dashboard = () => { title={t("Number of Trees Planted")} type="legend" secondOptionsData={LABEL_LEGEND} - tooltip={t( - "Total number of trees that funded projects have planted to date, including through assisted natural regeneration, as reported through 6-month progress reports and displayed as progress towards goal." - )} + tooltip={t(NUMBER_OF_TREES_PLANTED_TOOLTIP)} data={numberTreesPlanted} dataForChart={dashboardRestorationGoalData} chartType={CHART_TYPES.treesPlantedBarChart} @@ -281,7 +296,7 @@ const Dashboard = () => { data={{}} dataForChart={dashboardRestorationGoalData} chartType={CHART_TYPES.multiLineChart} - tooltip={t("Number of trees planted in each year.")} + tooltip={t(NUMBER_OF_TREES_PLANTED_BY_YEAR_TOOLTIP)} /> { secondOptionsData={dataToggleGraphic} data={topProject} isTableProject={true} - tooltip={t( - "The 5 projects that have planted the most trees and the number of trees planted per project. Please note that organization names are listed instead of project names for ease of reference." - )} + tooltip={t(TOP_5_PROJECTS_WITH_MOST_PLANTED_TREES_TOOLTIP)} /> @@ -302,11 +315,10 @@ const Dashboard = () => { title={t("JOBS CREATED")} variantSubTitle="text-14-light" subtitleMore={true} + tooltipTrigger="click" widthTooltip="w-80 lg:w-96" iconClassName="h-3.5 w-3.5 text-darkCustom lg:h-5 lg:w-5" - tooltip={t( - "This section displays data related to Indicator 3: Jobs Created described in TerraFund’s Monitoring, Reporting, and Verification framework. TerraFund defines a job as a set of tasks and duties performed by one person aged 18 years or older in exchange for monetary pay in line with living wage standards. All indicators in the Jobs Created category are disaggregated by number of women, number of men, and number of youths. Restoration Champions are required to report on jobs and volunteers every 6 months and provide additional documentation to verify employment. Please refer to the linked framework for additional details on how these numbers are sourced and verified." - )} + tooltip={t(JOBS_CREATED_SECTION_TOOLTIP)} subtitle={t( `The numbers and reports below display data related to Indicator 3: Jobs Created described in TerraFund's MRV framework. TerraFund defines a job as a set of tasks and duties performed by one person aged 18 or over in exchange for monetary pay in line with living wage standards. All indicators in the Jobs Created category are disaggregated by number of women, number of men, and number of youths. Restoration Champions are required to report on jobs and volunteers every 6 months and provide additional documentation to verify employment. Please refer to the linked MRV framework for additional details on how these numbers are sourced and verified.` )} @@ -316,18 +328,14 @@ const Dashboard = () => { title={t("New Part-Time Jobs")} data={{ value: jobsCreatedData?.data?.total_pt }} classNameBody="w-full place-content-center" - tooltip={t( - "Number of part-time jobs created to date. TerraFund defines a part-time job as under 35 hours per work week." - )} + tooltip={t(NEW_PART_TIME_JOBS_TOOLTIP)} />
@@ -338,7 +346,7 @@ const Dashboard = () => { chartType="groupedBarChart" classNameHeader="!justify-center" classNameBody="w-full place-content-center !justify-center flex-col gap-5" - tooltip={t("Total number of jobs created broken down by gender.")} + tooltip={t(JOBS_CREATED_BY_GENDER_TOOLTIP)} /> { chartType="groupedBarChart" classNameHeader="!justify-center" classNameBody="w-full place-content-center !justify-center flex-col gap-5" - tooltip={t( - "Total number of jobs created broken down by age group. Youth is defined as 18-35 years old. Non-youth is defined as older than 35 years old." - )} + tooltip={t(JOBS_CREATED_BY_AGE_TOOLTIP)} />
{ dataForChart={parseVolunteersByType(dashboardVolunteersSurvivalRate, JOBS_CREATED_CHART_TYPE.gender)} classNameHeader="!justify-center" classNameBody="w-full place-content-center !justify-center flex-col gap-5" - tooltip={t("Total number of volunteers broken down by gender.")} + tooltip={t(VOLUNTEERS_CREATED_BY_GENDER_TOOLTIP)} /> { dataForChart={parseVolunteersByType(dashboardVolunteersSurvivalRate, JOBS_CREATED_CHART_TYPE.age)} classNameHeader="!justify-center" classNameBody="w-full place-content-center !justify-center flex-col gap-5" - tooltip={t( - "Total number of volunteers broken down by age group. Youth is defined as 18-35 years old. Non-youth is defined as older than 35 years old." - )} + tooltip={t(VOLUNTEERS_CREATED_BY_AGE_TOOLTIP)} />
@@ -391,8 +393,10 @@ const Dashboard = () => { titleTable={t(filters.country.id === 0 ? "ACTIVE COUNTRIES" : "ACTIVE PROJECTS")} textTooltipTable={t( filters.country.id === 0 - ? "For each country, this table shows the number of projects, trees planted, hectares under restoration, and jobs created to date." - : "For each project, this table shows the number of trees planted, hectares under restoration, jobs created, and volunteers engaged to date. Those with access to individual project pages can click directly on table rows to dive deep." + ? ACTIVE_COUNTRIES_TOOLTIP + : DATA_ACTIVE_COUNTRY.length > 0 + ? ACTIVE_PROJECTS_TOOLTIP + : NO_DATA_PRESENT_ACTIVE_PROJECT_TOOLTIPS )} polygonsData={polygonsData} /> diff --git a/src/pages/dashboard/project/index.page.tsx b/src/pages/dashboard/project/index.page.tsx index 8595e9f04..807965374 100644 --- a/src/pages/dashboard/project/index.page.tsx +++ b/src/pages/dashboard/project/index.page.tsx @@ -10,6 +10,24 @@ import PageRow from "@/components/extensive/PageElements/Row/PageRow"; import ContentOverview from "../components/ContentOverview"; import SecDashboard from "../components/SecDashboard"; +import { + ACTIVE_PROJECTS_TOOLTIP, + HECTARES_UNDER_RESTORATION_TOOLTIP, + JOBS_CREATED_BY_AGE_TOOLTIP, + JOBS_CREATED_BY_GENDER_TOOLTIP, + JOBS_CREATED_SECTION_TOOLTIP, + JOBS_CREATED_TOOLTIP, + NEW_FULL_TIME_JOBS_TOOLTIP, + NEW_PART_TIME_JOBS_TOOLTIP, + NUMBER_OF_TREES_PLANTED_BY_YEAR_TOOLTIP, + NUMBER_OF_TREES_PLANTED_TOOLTIP, + TOP_5_PROJECTS_WITH_MOST_PLANTED_TREES_TOOLTIP, + TOTAL_VOLUNTEERS_TOOLTIP, + TREES_PLANTED_TOOLTIP, + TREES_RESTORED_SECTION_TOOLTIP, + VOLUNTEERS_CREATED_BY_AGE_TOOLTIP, + VOLUNTEERS_CREATED_BY_GENDER_TOOLTIP +} from "../constants/tooltips"; import { RefContext } from "../context/ScrollContext.provider"; import { DATA_ACTIVE_COUNTRY, @@ -63,20 +81,17 @@ const ProjectView = () => { { label: "Trees Planted", value: "0", - tooltip: - "Total number of trees planted by funded projects to date, including through assisted natural regeneration, as reported through six-month progress reports." + tooltip: TREES_PLANTED_TOOLTIP }, { label: "Hectares Under Restoration", value: "0 ha", - tooltip: - "Total land area measured in hectares with active restoration interventions, tallied by the total area of polygons submitted by projects and approved by data quality analysts." + tooltip: HECTARES_UNDER_RESTORATION_TOOLTIP }, { label: "Jobs Created", value: "0", - tooltip: - "Number of jobs created to date. TerraFund defines a job as a set of tasks and duties performed by one person aged 18 or over in exchange for monetary pay in line with living wage standards." + tooltip: JOBS_CREATED_TOOLTIP } ]; @@ -194,9 +209,7 @@ const ProjectView = () => { gap={8} subtitleMore={true} title={t("TREES RESTORED")} - tooltip={t( - "This section displays data related to Indicator 1: Trees Restored described in TerraFund’s Monitoring, Reporting, and Verification framework. Please refer to the linked framework for details on how these numbers are sourced and verified." - )} + tooltip={t(TREES_RESTORED_SECTION_TOOLTIP)} widthTooltip="w-52 lg:w-64" iconClassName="h-3.5 w-3.5 text-darkCustom lg:h-5 lg:w-5" variantSubTitle="text-14-light" @@ -209,9 +222,7 @@ const ProjectView = () => { type="legend" secondOptionsData={LABEL_LEGEND} data={NUMBER_OF_TREES_PLANTED} - tooltip={t( - "Total number of trees that funded projects have planted to date, including through assisted natural regeneration, as reported through 6-month progress reports and displayed as progress towards goal." - )} + tooltip={t(NUMBER_OF_TREES_PLANTED_TOOLTIP)} /> { secondOptionsData={dataToggle} tooltipGraphic={true} data={NUMBER_OF_TREES_PLANTED_BY_YEAR} - tooltip={t("Number of trees planted in each year.")} + tooltip={t(NUMBER_OF_TREES_PLANTED_BY_YEAR_TOOLTIP)} /> { title={t("JOBS CREATED")} variantSubTitle="text-14-light" subtitleMore={true} + tooltipTrigger="click" widthTooltip="w-80 lg:w-96" iconClassName="h-3.5 w-3.5 text-darkCustom lg:h-5 lg:w-5" - tooltip={t( - "This section displays data related to Indicator 3: Jobs Created described in TerraFund’s Monitoring, Reporting, and Verification framework. TerraFund defines a job as a set of tasks and duties performed by one person aged 18 years or older in exchange for monetary pay in line with living wage standards. All indicators in the Jobs Created category are disaggregated by number of women, number of men, and number of youths. Restoration Champions are required to report on jobs and volunteers every 6 months and provide additional documentation to verify employment. Please refer to the linked framework for additional details on how these numbers are sourced and verified." - )} + tooltip={t(JOBS_CREATED_SECTION_TOOLTIP)} subtitle={t( `The numbers and reports below display data related to Indicator 3: Jobs Created described in TerraFund’s MRV framework. TerraFund defines a job as a set of tasks and duties performed by one person aged 18 or over in exchange for monetary pay in line with living wage standards. All indicators in the Jobs Created category are disaggregated by number of women, number of men, and number of youths. Restoration Champions are required to report on jobs and volunteers every 6 months and provide additional documentation to verify employment. Please refer to the linked MRV framework for additional details on how these numbers are sourced and verified.` )} @@ -252,18 +260,14 @@ const ProjectView = () => { title={t("New Part-Time Jobs")} data={NEW_PART_TIME_JOBS} classNameBody="w-full place-content-center" - tooltip={t( - "Number of part-time jobs created to date. TerraFund defines a part-time job as under 35 hours per work week." - )} + tooltip={t(NEW_PART_TIME_JOBS_TOOLTIP)} />
@@ -272,41 +276,31 @@ const ProjectView = () => { data={JOBS_CREATED_BY_GENDER} classNameHeader="!justify-center" classNameBody="w-full place-content-center !justify-center flex-col gap-5" - tooltip={t("Total number of jobs created broken down by gender.")} + tooltip={t(JOBS_CREATED_BY_GENDER_TOOLTIP)} />
- +
@@ -316,9 +310,7 @@ const ProjectView = () => { dataTable={DATA_ACTIVE_COUNTRY} columns={COLUMN_ACTIVE_COUNTRY} titleTable={t("ACTIVE PROJECTS")} - textTooltipTable={t( - "For each project, this table shows the number of trees planted, hectares under restoration, jobs created, and volunteers engaged to date. Those with access to individual project pages can click directly on table rows to dive deep." - )} + textTooltipTable={t(ACTIVE_PROJECTS_TOOLTIP)} />
); From 88270899afb85c9dfa12cf4c9f162644bc4e44d4 Mon Sep 17 00:00:00 2001 From: cesarLima1 <105736261+cesarLima1@users.noreply.github.com> Date: Wed, 23 Oct 2024 12:50:47 -0400 Subject: [PATCH 040/102] [TM-1374] add data to target land use types graphic (#588) * [TM-1374] add data to target land use types graphic * [TM-1374] fix issues with component * [TM-1371] show value without decimals --- .../{dashbordConsts.ts => dashboardConsts.ts} | 3 +- src/generated/apiComponents.ts | 150 +++++++++++++++++ src/generated/apiSchemas.ts | 152 ++++++++++++++++++ src/pages/dashboard/[id].page.tsx | 5 + src/pages/dashboard/charts/MultiLineChart.tsx | 2 +- .../dashboard/components/ContentOverview.tsx | 17 +- .../components/GraphicIconDashboard.tsx | 90 +++++++++++ .../components/GraphicIconDashoard.tsx | 89 ---------- .../dashboard/components/SecDashboard.tsx | 11 +- src/pages/dashboard/hooks/useDashboardData.ts | 7 + src/pages/dashboard/index.page.tsx | 11 +- src/pages/dashboard/project/index.page.tsx | 6 + src/utils/dashboardUtils.ts | 126 ++++++++++++++- 13 files changed, 568 insertions(+), 101 deletions(-) rename src/constants/{dashbordConsts.ts => dashboardConsts.ts} (90%) create mode 100644 src/pages/dashboard/components/GraphicIconDashboard.tsx delete mode 100644 src/pages/dashboard/components/GraphicIconDashoard.tsx diff --git a/src/constants/dashbordConsts.ts b/src/constants/dashboardConsts.ts similarity index 90% rename from src/constants/dashbordConsts.ts rename to src/constants/dashboardConsts.ts index eaaf5d901..b27cd05ba 100644 --- a/src/constants/dashbordConsts.ts +++ b/src/constants/dashboardConsts.ts @@ -2,7 +2,8 @@ export const CHART_TYPES = { multiLineChart: "multiLineChart", treesPlantedBarChart: "treesPlantedBarChart", groupedBarChart: "groupedBarChart", - doughnutChart: "doughnutChart" + doughnutChart: "doughnutChart", + barChart: "barChart" }; export const JOBS_CREATED_CHART_TYPE = { diff --git a/src/generated/apiComponents.ts b/src/generated/apiComponents.ts index b927b6649..d6d8b50d7 100644 --- a/src/generated/apiComponents.ts +++ b/src/generated/apiComponents.ts @@ -34403,6 +34403,151 @@ export const useGetV2DashboardTopTreesPlanted = ; + +export type GetV2DashboardIndicatorHectaresRestorationResponse = { + data?: { + restoration_strategies_represented?: { + /** + * Total amount for tree planting projects. + */ + ["tree-planting"]?: number; + /** + * Total amount for projects involving both tree planting and direct seeding. + */ + ["tree-planting,direct-seeding"]?: number; + /** + * Total amount for assisted natural regeneration projects. + */ + ["assisted-natural-regeneration"]?: number; + /** + * Total amount for projects involving both tree planting and assisted natural regeneration. + */ + ["tree-planting,assisted-natural-regeneration"]?: number; + /** + * Total amount for direct seeding projects. + */ + ["direct-seeding"]?: number; + /** + * Total amount for control projects. + */ + control?: number; + /** + * Total amount for projects with no specific restoration category. + */ + ["null"]?: number; + }; + target_land_use_types_represented?: { + /** + * Total amount for projects without a defined land use type. + */ + ["null"]?: number; + /** + * Total amount for projects involving natural forest. + */ + ["natural-forest"]?: number; + /** + * Total amount for agroforest projects. + */ + agroforest?: number; + /** + * Total amount for silvopasture projects. + */ + silvopasture?: number; + /** + * Total amount for woodlot or plantation projects. + */ + ["woodlot-or-plantation"]?: number; + /** + * Total amount for riparian area or wetland projects. + */ + ["riparian-area-or-wetland"]?: number; + /** + * Total amount for projects involving both agroforest and riparian area or wetland. + */ + ["agroforest,riparian-area-or-wetland"]?: number; + /** + * Total amount for projects involving both riparian area or wetland and woodlot or plantation. + */ + ["riparian-area-or-wetland,woodlot-or-plantation"]?: number; + /** + * Total amount for projects involving open natural ecosystem or grasslands. + */ + ["Open natural ecosystem or Grasslands"]?: number; + /** + * Total amount for urban forest projects. + */ + ["urban-forest"]?: number; + }; + }; +}; + +export type GetV2DashboardIndicatorHectaresRestorationVariables = { + queryParams?: GetV2DashboardIndicatorHectaresRestorationQueryParams; +} & ApiContext["fetcherOptions"]; + +/** + * This endpoint returns hectares restored using data from indicators 5 (restoration strategies) and 6 (target land use types). + */ +export const fetchGetV2DashboardIndicatorHectaresRestoration = ( + variables: GetV2DashboardIndicatorHectaresRestorationVariables, + signal?: AbortSignal +) => + apiFetch< + GetV2DashboardIndicatorHectaresRestorationResponse, + GetV2DashboardIndicatorHectaresRestorationError, + undefined, + {}, + GetV2DashboardIndicatorHectaresRestorationQueryParams, + {} + >({ url: "/v2/dashboard/indicator/hectares-restoration", method: "get", ...variables, signal }); + +/** + * This endpoint returns hectares restored using data from indicators 5 (restoration strategies) and 6 (target land use types). + */ +export const useGetV2DashboardIndicatorHectaresRestoration = < + TData = GetV2DashboardIndicatorHectaresRestorationResponse +>( + variables: GetV2DashboardIndicatorHectaresRestorationVariables, + options?: Omit< + reactQuery.UseQueryOptions< + GetV2DashboardIndicatorHectaresRestorationResponse, + GetV2DashboardIndicatorHectaresRestorationError, + TData + >, + "queryKey" | "queryFn" + > +) => { + const { fetcherOptions, queryOptions, queryKeyFn } = useApiContext(options); + return reactQuery.useQuery< + GetV2DashboardIndicatorHectaresRestorationResponse, + GetV2DashboardIndicatorHectaresRestorationError, + TData + >( + queryKeyFn({ + path: "/v2/dashboard/indicator/hectares-restoration", + operationId: "getV2DashboardIndicatorHectaresRestoration", + variables + }), + ({ signal }) => fetchGetV2DashboardIndicatorHectaresRestoration({ ...fetcherOptions, ...variables }, signal), + { + ...options, + ...queryOptions + } + ); +}; + export type GetV2ProjectPipelineQueryParams = { /** * Optional. Filter counts and metrics by country. @@ -36534,6 +36679,11 @@ export type QueryOperation = operationId: "getV2DashboardTopTreesPlanted"; variables: GetV2DashboardTopTreesPlantedVariables; } + | { + path: "/v2/dashboard/indicator/hectares-restoration"; + operationId: "getV2DashboardIndicatorHectaresRestoration"; + variables: GetV2DashboardIndicatorHectaresRestorationVariables; + } | { path: "/v2/project-pipeline"; operationId: "getV2ProjectPipeline"; diff --git a/src/generated/apiSchemas.ts b/src/generated/apiSchemas.ts index 2fe52628f..7a38edc4f 100644 --- a/src/generated/apiSchemas.ts +++ b/src/generated/apiSchemas.ts @@ -23417,3 +23417,155 @@ export type FileResource = { is_public?: boolean; is_cover?: boolean; }; + +export type DashboardIndicatorHectaresRestorationResponse = { + data?: { + restoration_strategies_represented?: { + /** + * Total amount for tree planting projects. + */ + ["tree-planting"]?: number; + /** + * Total amount for projects involving both tree planting and direct seeding. + */ + ["tree-planting,direct-seeding"]?: number; + /** + * Total amount for assisted natural regeneration projects. + */ + ["assisted-natural-regeneration"]?: number; + /** + * Total amount for projects involving both tree planting and assisted natural regeneration. + */ + ["tree-planting,assisted-natural-regeneration"]?: number; + /** + * Total amount for direct seeding projects. + */ + ["direct-seeding"]?: number; + /** + * Total amount for control projects. + */ + control?: number; + /** + * Total amount for projects with no specific restoration category. + */ + ["null"]?: number; + }; + target_land_use_types_represented?: { + /** + * Total amount for projects without a defined land use type. + */ + ["null"]?: number; + /** + * Total amount for projects involving natural forest. + */ + ["natural-forest"]?: number; + /** + * Total amount for agroforest projects. + */ + agroforest?: number; + /** + * Total amount for silvopasture projects. + */ + silvopasture?: number; + /** + * Total amount for woodlot or plantation projects. + */ + ["woodlot-or-plantation"]?: number; + /** + * Total amount for riparian area or wetland projects. + */ + ["riparian-area-or-wetland"]?: number; + /** + * Total amount for projects involving both agroforest and riparian area or wetland. + */ + ["agroforest,riparian-area-or-wetland"]?: number; + /** + * Total amount for projects involving both riparian area or wetland and woodlot or plantation. + */ + ["riparian-area-or-wetland,woodlot-or-plantation"]?: number; + /** + * Total amount for projects involving open natural ecosystem or grasslands. + */ + ["Open natural ecosystem or Grasslands"]?: number; + /** + * Total amount for urban forest projects. + */ + ["urban-forest"]?: number; + }; + }; +}; + +export type DashboardIndicatorHectaresRestorationData = { + restoration_strategies_represented?: { + /** + * Total amount for tree planting projects. + */ + ["tree-planting"]?: number; + /** + * Total amount for projects involving both tree planting and direct seeding. + */ + ["tree-planting,direct-seeding"]?: number; + /** + * Total amount for assisted natural regeneration projects. + */ + ["assisted-natural-regeneration"]?: number; + /** + * Total amount for projects involving both tree planting and assisted natural regeneration. + */ + ["tree-planting,assisted-natural-regeneration"]?: number; + /** + * Total amount for direct seeding projects. + */ + ["direct-seeding"]?: number; + /** + * Total amount for control projects. + */ + control?: number; + /** + * Total amount for projects with no specific restoration category. + */ + ["null"]?: number; + }; + target_land_use_types_represented?: { + /** + * Total amount for projects without a defined land use type. + */ + ["null"]?: number; + /** + * Total amount for projects involving natural forest. + */ + ["natural-forest"]?: number; + /** + * Total amount for agroforest projects. + */ + agroforest?: number; + /** + * Total amount for silvopasture projects. + */ + silvopasture?: number; + /** + * Total amount for woodlot or plantation projects. + */ + ["woodlot-or-plantation"]?: number; + /** + * Total amount for riparian area or wetland projects. + */ + ["riparian-area-or-wetland"]?: number; + /** + * Total amount for projects involving both agroforest and riparian area or wetland. + */ + ["agroforest,riparian-area-or-wetland"]?: number; + /** + * Total amount for projects involving both riparian area or wetland and woodlot or plantation. + */ + ["riparian-area-or-wetland,woodlot-or-plantation"]?: number; + /** + * Total amount for projects involving open natural ecosystem or grasslands. + */ + ["Open natural ecosystem or Grasslands"]?: number; + /** + * Total amount for urban forest projects. + */ + ["urban-forest"]?: number; + }; +}; diff --git a/src/pages/dashboard/[id].page.tsx b/src/pages/dashboard/[id].page.tsx index 75bc39b0a..3206848fc 100644 --- a/src/pages/dashboard/[id].page.tsx +++ b/src/pages/dashboard/[id].page.tsx @@ -258,6 +258,11 @@ const Country: React.FC = ({ selectedCountry }) => {
{ titleTable: string; textTooltipTable?: string; centroids?: DashboardGetProjectsData[]; + dataHectaresUnderRestoration: HectaresUnderRestorationData; polygonsData?: any; } const ContentOverview = (props: ContentOverviewProps) => { - const { dataTable: data, columns, titleTable, textTooltipTable, centroids, polygonsData } = props; + const { + dataTable: data, + columns, + titleTable, + textTooltipTable, + centroids, + polygonsData, + dataHectaresUnderRestoration + } = props; const t = useT(); const modalMapFunctions = useMap(); const dashboardMapFunctions = useMap(); @@ -191,7 +201,8 @@ const ContentOverview = (props: ContentOverviewProps) => { /> diff --git a/src/pages/dashboard/components/GraphicIconDashboard.tsx b/src/pages/dashboard/components/GraphicIconDashboard.tsx new file mode 100644 index 000000000..d7eb34584 --- /dev/null +++ b/src/pages/dashboard/components/GraphicIconDashboard.tsx @@ -0,0 +1,90 @@ +import { useT } from "@transifex/react"; +import classNames from "classnames"; +import { When } from "react-if"; + +import Text from "@/components/elements/Text/Text"; +import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; +import { getPercentage } from "@/utils/dashboardUtils"; + +import { DashboardTableDataProps } from "../index.page"; + +const GraphicIconDashboard = ({ data, maxValue }: { data: DashboardTableDataProps[]; maxValue: number }) => { + const t = useT(); + + const colorIconLabel = (label: string): { color: string; icon: keyof typeof IconNames } => { + switch (label) { + case "Agroforest": + return { color: "bg-secondary-600", icon: "IC_AGROFOREST" }; + case "Natural Forest": + return { color: "bg-green-60", icon: "IC_NATURAL_FOREST" }; + case "Mangrove": + return { color: "bg-green-35", icon: "IC_MANGROVE" }; + case "Woodlot / Plantation": + return { color: "bg-yellow-600", icon: "IC_WOODLOT" }; + case "Open Natural Ecosystem": + return { color: "bg-green-40", icon: "IC_OPEN_NATURAL_ECOSYSTEM" }; + case "Riparian Area / Wetland": + return { color: "bg-primary-350", icon: "IC_RIPARIAN_AREA" }; + case "Urban Forest": + return { color: "bg-blueCustom", icon: "IC_URBAN_FOREST" }; + case "Silvopasture": + return { color: "bg-yellow-550", icon: "IC_SILVOPASTURE" }; + case "Peatland": + return { color: "bg-primary", icon: "IC_PEATLAND" }; + default: + return { color: "bg-tertiary-800", icon: "IC_AGROFOREST" }; + } + }; + + return ( +
+ 0}> +
+ {data.map((item, index) => { + const percentage = getPercentage(item.value, maxValue); + return ( +
+ ); + })} +
+
+ {data.map((item, index) => { + const percentage = getPercentage(item.value, maxValue); + return ( +
+
+
+ + + {t(item.label)} + +
+ + {t(item.valueText)} + +
+
+
+
+
+ ); + })} +
+ +
+ ); +}; + +export default GraphicIconDashboard; diff --git a/src/pages/dashboard/components/GraphicIconDashoard.tsx b/src/pages/dashboard/components/GraphicIconDashoard.tsx deleted file mode 100644 index f01b9ae42..000000000 --- a/src/pages/dashboard/components/GraphicIconDashoard.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useT } from "@transifex/react"; -import classNames from "classnames"; - -import Text from "@/components/elements/Text/Text"; -import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; - -import { DashboardTableDataProps } from "../index.page"; - -const GraphicIconDashoard = ({ data }: { data: DashboardTableDataProps[] }) => { - const t = useT(); - - const colorIconLabel = (label: string): { color: string; icon: keyof typeof IconNames } => { - switch (label) { - case "Agroforest": - return { color: "bg-secondary-600", icon: "IC_AGROFOREST" }; - - case "Natural Forest": - return { color: "bg-green-60", icon: "IC_NATURAL_FOREST" }; - - case "Mangrove": - return { color: "bg-green-35", icon: "IC_MANGROVE" }; - - case "Woodlot / Plantation": - return { color: "bg-yellow-600", icon: "IC_WOODLOT" }; - - case "Open Natural Ecosystem": - return { color: "bg-green-40", icon: "IC_OPEN_NATURAL_ECOSYSTEM" }; - - case "Riparian Area / Wetland": - return { color: "bg-primary-350", icon: "IC_RIPARIAN_AREA" }; - - case "Urban Forest": - return { color: "bg-blueCustom", icon: "IC_URBAN_FOREST" }; - - case "Silvopasture": - return { color: "bg-yellow-550", icon: "IC_SILVOPASTURE" }; - case "Peatland": - return { color: "bg-primary", icon: "IC_PEATLAND" }; - default: - return { color: "bg-tertiary-800", icon: "IC_AGROFOREST" }; - } - }; - - return ( -
-
- {data.map((item, index) => { - return ( -
- ); - })} -
-
- {data.map((item, index) => ( -
-
-
- - - {t(item.label)} - -
- - {t(item.valueText)} - -
-
-
-
-
- ))} -
-
- ); -}; - -export default GraphicIconDashoard; diff --git a/src/pages/dashboard/components/SecDashboard.tsx b/src/pages/dashboard/components/SecDashboard.tsx index 128e7c2a1..e144e9876 100644 --- a/src/pages/dashboard/components/SecDashboard.tsx +++ b/src/pages/dashboard/components/SecDashboard.tsx @@ -10,7 +10,7 @@ import Toggle from "@/components/elements/Toggle/Toggle"; import { VARIANT_TOGGLE_DASHBOARD } from "@/components/elements/Toggle/ToggleVariants"; import ToolTip from "@/components/elements/Tooltip/Tooltip"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; -import { CHART_TYPES } from "@/constants/dashbordConsts"; +import { CHART_TYPES } from "@/constants/dashboardConsts"; import { TextVariants } from "@/types/common"; import { getRestorationGoalDataForChart, getRestorationGoalResumeData } from "@/utils/dashboardUtils"; @@ -20,7 +20,7 @@ import HorizontalStackedBarChart from "../charts/HorizontalStackedBarChart"; import MultiLineChart from "../charts/MultiLineChart"; import { DashboardDataProps } from "../project/index.page"; import GraphicDashboard from "./GraphicDashboard"; -import GraphicIconDashoard from "./GraphicIconDashoard"; +import GraphicIconDashboard from "./GraphicIconDashboard"; import ObjectiveSec from "./ObjectiveSec"; import ValueNumberDashboard from "./ValueNumberDashboard"; @@ -192,8 +192,11 @@ const SecDashboard = ({
- - + + diff --git a/src/pages/dashboard/hooks/useDashboardData.ts b/src/pages/dashboard/hooks/useDashboardData.ts index 826f8f33e..8efceb893 100644 --- a/src/pages/dashboard/hooks/useDashboardData.ts +++ b/src/pages/dashboard/hooks/useDashboardData.ts @@ -7,6 +7,7 @@ import { useGetV2DashboardActiveProjects, useGetV2DashboardGetPolygonsStatuses, useGetV2DashboardGetProjects, + useGetV2DashboardIndicatorHectaresRestoration, useGetV2DashboardJobsCreated, useGetV2DashboardTopTreesPlanted, useGetV2DashboardTotalSectionHeader, @@ -106,6 +107,10 @@ export const useDashboardData = (filters: any) => { queryParams: queryParams }); + const { data: hectaresUnderRestoration } = useGetV2DashboardIndicatorHectaresRestoration({ + queryParams: queryParams + }); + useEffect(() => { if (topData?.data) { const projects = topData.data.top_projects_most_planted_trees.slice(0, 5); @@ -143,6 +148,8 @@ export const useDashboardData = (filters: any) => { jobsCreatedData, dashboardVolunteersSurvivalRate, numberTreesPlanted, + totalSectionHeader, + hectaresUnderRestoration, topProject, refetchTotalSectionHeader, activeCountries, diff --git a/src/pages/dashboard/index.page.tsx b/src/pages/dashboard/index.page.tsx index 85a23b269..2675fa302 100644 --- a/src/pages/dashboard/index.page.tsx +++ b/src/pages/dashboard/index.page.tsx @@ -7,9 +7,9 @@ import ToolTip from "@/components/elements/Tooltip/Tooltip"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import PageCard from "@/components/extensive/PageElements/Card/PageCard"; import PageRow from "@/components/extensive/PageElements/Row/PageRow"; -import { CHART_TYPES, JOBS_CREATED_CHART_TYPE } from "@/constants/dashbordConsts"; +import { CHART_TYPES, JOBS_CREATED_CHART_TYPE } from "@/constants/dashboardConsts"; import { useDashboardContext } from "@/context/dashboard.provider"; -import { formatLabelsVolunteers } from "@/utils/dashboardUtils"; +import { formatLabelsVolunteers, parseHectaresUnderRestorationData } from "@/utils/dashboardUtils"; import ContentOverview from "./components/ContentOverview"; import SecDashboard from "./components/SecDashboard"; @@ -55,6 +55,8 @@ const Dashboard = () => { dashboardRestorationGoalData, jobsCreatedData, dashboardVolunteersSurvivalRate, + totalSectionHeader, + hectaresUnderRestoration, numberTreesPlanted, topProject, refetchTotalSectionHeader, @@ -391,6 +393,11 @@ const Dashboard = () => { centroids={centroidsDataProjects} columns={filters.country.id === 0 ? COLUMN_ACTIVE_PROGRAMME : COLUMN_ACTIVE_COUNTRY} titleTable={t(filters.country.id === 0 ? "ACTIVE COUNTRIES" : "ACTIVE PROJECTS")} + dataHectaresUnderRestoration={parseHectaresUnderRestorationData( + totalSectionHeader, + dashboardVolunteersSurvivalRate, + hectaresUnderRestoration + )} textTooltipTable={t( filters.country.id === 0 ? ACTIVE_COUNTRIES_TOOLTIP diff --git a/src/pages/dashboard/project/index.page.tsx b/src/pages/dashboard/project/index.page.tsx index 807965374..ee6e460ea 100644 --- a/src/pages/dashboard/project/index.page.tsx +++ b/src/pages/dashboard/project/index.page.tsx @@ -64,6 +64,7 @@ export interface DashboardDataProps { graphic?: string; tableData?: DashboardTableDataProps[]; maxValue?: number; + totalSection?: { numberOfSites: number; totalHectaresRestored: number }; graphicLegend?: GraphicLegendProps[]; graphicTargetLandUseTypes?: DashboardTableDataProps[]; objetiveText?: string; @@ -310,6 +311,11 @@ const ProjectView = () => { dataTable={DATA_ACTIVE_COUNTRY} columns={COLUMN_ACTIVE_COUNTRY} titleTable={t("ACTIVE PROJECTS")} + dataHectaresUnderRestoration={{ + totalSection: { numberOfSites: 0, totalHectaresRestored: 0 }, + restorationStrategiesRepresented: [], + graphicTargetLandUseTypes: [] + }} textTooltipTable={t(ACTIVE_PROJECTS_TOOLTIP)} />
diff --git a/src/utils/dashboardUtils.ts b/src/utils/dashboardUtils.ts index 589a1ab79..61df21c13 100644 --- a/src/utils/dashboardUtils.ts +++ b/src/utils/dashboardUtils.ts @@ -1,4 +1,4 @@ -import { MONTHS } from "@/constants/dashbordConsts"; +import { MONTHS } from "@/constants/dashboardConsts"; import { DashboardTreeRestorationGoalResponse } from "@/generated/apiSchemas"; type DataPoint = { @@ -31,6 +31,57 @@ export interface ChartDataVolunteers { total: number; } +interface Option { + title: string; + value: string; +} + +interface TotalSectionHeader { + country_name: string; + total_enterprise_count: number; + total_entries: number; + total_hectares_restored: number; + total_hectares_restored_goal: number; + total_non_profit_count: number; + total_trees_restored: number; + total_trees_restored_goal: number; +} + +interface DashboardVolunteersSurvivalRate { + enterprise_survival_rate: number; + men_volunteers: number; + non_profit_survival_rate: number; + non_youth_volunteers: number; + number_of_nurseries: number; + number_of_sites: number; + total_volunteers: number; + women_volunteers: number; + youth_volunteers: number; +} + +interface HectaresUnderRestoration { + restoration_strategies_represented: Record; + target_land_use_types_represented: Record; +} + +interface ParsedDataItem { + label: string; + value: number; +} + +interface ParsedLandUseType extends ParsedDataItem { + valueText: string; +} + +export interface HectaresUnderRestorationData { + totalSection: { + totalHectaresRestored: number; + numberOfSites: number; + }; + restorationStrategiesRepresented: ParsedDataItem[]; + graphicTargetLandUseTypes: ParsedLandUseType[]; +} + export const formatNumberUS = (value: number) => value ? (value >= 1000000 ? `${(value / 1000000).toFixed(2)}M` : value.toLocaleString("en-US")) : ""; @@ -152,6 +203,11 @@ export const getPercentage = (value: number, total: number): string => { return ((value / total) * 100).toFixed(1); }; +export const calculatePercentage = (value: number, total: number): number => { + if (!total) return 0; + return Number(((value / total) * 100).toFixed(1)); +}; + export const calculateTotals = (data: GroupedBarChartData): { [key: string]: number } => { return data.chartData.reduce((acc, item) => { const key1 = data.type === "gender" ? "Women" : "Youth"; @@ -185,3 +241,71 @@ export const calculateTotalsVolunteers = (chartData: ChartDataItem[]): { [key: s return acc; }, {}); }; + +const landUseTypeOptions: Option[] = [ + { title: "Agroforest", value: "agroforest" }, + { title: "Mangrove", value: "mangrove" }, + { title: "Natural Forest", value: "natural-forest" }, + { title: "Silvopasture", value: "silvopasture" }, + { title: "Riparian Area or Wetland", value: "riparian-area-or-wetland" }, + { title: "Urban Forest", value: "urban-forest" }, + { title: "Woodlot or Plantation", value: "woodlot-or-plantation" }, + { title: "Peatland", value: "peatland" }, + { title: "Open Natural Ecosystem", value: "open-natural-ecosystem" } +]; + +export const parseHectaresUnderRestorationData = ( + totalSectionHeader: TotalSectionHeader, + dashboardVolunteersSurvivalRate: DashboardVolunteersSurvivalRate, + hectaresUnderRestoration: HectaresUnderRestoration +): HectaresUnderRestorationData => { + if (!totalSectionHeader || !dashboardVolunteersSurvivalRate || !hectaresUnderRestoration) { + return { + totalSection: { + totalHectaresRestored: 0, + numberOfSites: 0 + }, + restorationStrategiesRepresented: [], + graphicTargetLandUseTypes: [] + }; + } + const { total_hectares_restored } = totalSectionHeader; + const { number_of_sites } = dashboardVolunteersSurvivalRate; + + const objectToArray = (obj: Record = {}): ParsedDataItem[] => { + return Object.entries(obj).map(([name, value]) => ({ + label: name, + value + })); + }; + + const formatValueText = (value: number): string => { + if (!total_hectares_restored) return "0 ha 0%"; + const percentage = (value / total_hectares_restored) * 100; + return `${value.toFixed(0)} ha ${percentage.toFixed(2)}%`; + }; + + const getLandUseTypeTitle = (value: string): string => { + const option = landUseTypeOptions.find(opt => opt.value === value); + return option ? option.title : value; + }; + + const restorationStrategiesRepresented = objectToArray(hectaresUnderRestoration?.restoration_strategies_represented); + + const graphicTargetLandUseTypes = objectToArray(hectaresUnderRestoration?.target_land_use_types_represented).map( + item => ({ + label: getLandUseTypeTitle(item.label), + value: item.value, + valueText: formatValueText(item.value) + }) + ); + + return { + totalSection: { + totalHectaresRestored: total_hectares_restored ?? 0, + numberOfSites: number_of_sites ?? 0 + }, + restorationStrategiesRepresented, + graphicTargetLandUseTypes + }; +}; From 8ca593ae738df3583e4e9343e7965d69cf7cffc5 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Tue, 17 Sep 2024 17:02:33 -0700 Subject: [PATCH 041/102] [TM-1272] Initial API fetcher and types generation for v3. (cherry picked from commit f65bfc718d6b3e73325cffbc1ab94a0d1383bcac) --- openapi-codegen.config.ts | 25 +++++--- package.json | 3 +- src/generated/v3/apiV3Components.ts | 38 ++++++++++++ src/generated/v3/apiV3Fetcher.ts | 96 +++++++++++++++++++++++++++++ src/generated/v3/apiV3Schemas.ts | 28 +++++++++ 5 files changed, 182 insertions(+), 8 deletions(-) create mode 100644 src/generated/v3/apiV3Components.ts create mode 100644 src/generated/v3/apiV3Fetcher.ts create mode 100644 src/generated/v3/apiV3Schemas.ts diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 56dbd6121..418e0e387 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -1,8 +1,7 @@ import { defineConfig } from "@openapi-codegen/cli"; -import { generateReactQueryComponents, generateSchemaTypes } from "@openapi-codegen/typescript"; +import { generateFetchers, generateReactQueryComponents, generateSchemaTypes } from "@openapi-codegen/typescript"; import dotenv from "dotenv"; dotenv.config(); - export default defineConfig({ api: { from: { @@ -13,26 +12,21 @@ export default defineConfig({ to: async context => { let paths = context.openAPIDocument.paths; let newPaths: any = {}; - //! Treat carefully this might potentially break the api generation // This Logic will make sure every sigle endpoint has a `operationId` key (needed to generate endpoints) Object.keys(paths).forEach((k, i) => { newPaths[k] = {}; const eps = Object.keys(paths[k]).filter(ep => ep !== "parameters"); - eps.forEach((ep, i) => { const current = paths[k][ep]; const operationId = ep + k.replaceAll("/", "-").replaceAll("{", "").replaceAll("}", ""); - newPaths[k][ep] = { ...current, operationId }; }); }); - context.openAPIDocument.paths = newPaths; - const filenamePrefix = "api"; const { schemasFiles } = await generateSchemaTypes(context, { filenamePrefix @@ -42,5 +36,22 @@ export default defineConfig({ schemasFiles }); } + }, + apiV3: { + from: { + source: "url", + url: `${process.env.NEXT_PUBLIC_API_BASE_URL}/user-service/api-json` + }, + outputDir: "src/generated/v3", + to: async context => { + const filenamePrefix = "apiV3"; + const { schemasFiles } = await generateSchemaTypes(context, { + filenamePrefix + }); + await generateFetchers(context, { + filenamePrefix, + schemasFiles + }); + } } }); diff --git a/package.json b/package.json index aa764d798..098e52b3a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "postinstall": "patch-package", "dev": "next dev", - "build": "npm run generate:api && next build", + "build": "npm run generate:api && npm run generate:apiv3 && next build", "start": "next start", "test": "jest --watch", "test:coverage": "jest --coverage", @@ -16,6 +16,7 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "generate:api": "npx openapi-codegen gen api", + "generate:apiv3": "npx openapi-codegen gen apiV3", "tx:push": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli push --key-generator=hash src/ --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET", "tx:pull": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli pull --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET" }, diff --git a/src/generated/v3/apiV3Components.ts b/src/generated/v3/apiV3Components.ts new file mode 100644 index 000000000..796dd52e7 --- /dev/null +++ b/src/generated/v3/apiV3Components.ts @@ -0,0 +1,38 @@ +/** + * Generated by @openapi-codegen + * + * @version 1.0 + */ +import type * as Fetcher from "./apiV3Fetcher"; +import { apiV3Fetch } from "./apiV3Fetcher"; +import type * as Schemas from "./apiV3Schemas"; + +export type AuthControllerLoginError = Fetcher.ErrorWrapper<{ + status: 401; + payload: { + /** + * @example 401 + */ + statusCode: number; + /** + * @example Unauthorized + */ + message: string; + }; +}>; + +export type AuthControllerLoginResponse = { + data?: Schemas.LoginResponse; +}; + +export type AuthControllerLoginVariables = { + body: Schemas.LoginRequest; +}; + +export const authControllerLogin = (variables: AuthControllerLoginVariables, signal?: AbortSignal) => + apiV3Fetch({ + url: "/auth/v3/logins", + method: "post", + ...variables, + signal + }); diff --git a/src/generated/v3/apiV3Fetcher.ts b/src/generated/v3/apiV3Fetcher.ts new file mode 100644 index 000000000..8ecb06f38 --- /dev/null +++ b/src/generated/v3/apiV3Fetcher.ts @@ -0,0 +1,96 @@ +export type ApiV3FetcherExtraProps = { + /** + * You can add some extra props to your generated fetchers. + * + * Note: You need to re-gen after adding the first property to + * have the `ApiV3FetcherExtraProps` injected in `ApiV3Components.ts` + **/ +}; + +const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; + +export type ErrorWrapper = TError | { status: "unknown"; payload: string }; + +export type ApiV3FetcherOptions = { + url: string; + method: string; + body?: TBody; + headers?: THeaders; + queryParams?: TQueryParams; + pathParams?: TPathParams; + signal?: AbortSignal; +} & ApiV3FetcherExtraProps; + +export async function apiV3Fetch< + TData, + TError, + TBody extends {} | FormData | undefined | null, + THeaders extends {}, + TQueryParams extends {}, + TPathParams extends {} +>({ + url, + method, + body, + headers, + pathParams, + queryParams, + signal +}: ApiV3FetcherOptions): Promise { + try { + const requestHeaders: HeadersInit = { + "Content-Type": "application/json", + ...headers + }; + + /** + * As the fetch API is being used, when multipart/form-data is specified + * the Content-Type header must be deleted so that the browser can set + * the correct boundary. + * https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object + */ + if (requestHeaders["Content-Type"].toLowerCase().includes("multipart/form-data")) { + delete requestHeaders["Content-Type"]; + } + + const response = await window.fetch(`${baseUrl}${resolveUrl(url, queryParams, pathParams)}`, { + signal, + method: method.toUpperCase(), + body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined, + headers: requestHeaders + }); + if (!response.ok) { + let error: ErrorWrapper; + try { + error = await response.json(); + } catch (e) { + error = { + status: "unknown" as const, + payload: e instanceof Error ? `Unexpected error (${e.message})` : "Unexpected error" + }; + } + + throw error; + } + + if (response.headers.get("content-type")?.includes("json")) { + return await response.json(); + } else { + // if it is not a json response, assume it is a blob and cast it to TData + return (await response.blob()) as unknown as TData; + } + } catch (e) { + let errorObject: Error = { + name: "unknown" as const, + message: e instanceof Error ? `Network error (${e.message})` : "Network error", + stack: e as string + }; + throw errorObject; + } +} + +const resolveUrl = (url: string, queryParams: Record = {}, pathParams: Record = {}) => { + let query = new URLSearchParams(queryParams).toString(); + if (query) query = `?${query}`; + return url.replace(/\{\w*\}/g, key => pathParams[key.slice(1, -1)]) + query; +}; diff --git a/src/generated/v3/apiV3Schemas.ts b/src/generated/v3/apiV3Schemas.ts new file mode 100644 index 000000000..c95b87215 --- /dev/null +++ b/src/generated/v3/apiV3Schemas.ts @@ -0,0 +1,28 @@ +/** + * Generated by @openapi-codegen + * + * @version 1.0 + */ +export type LoginResponse = { + /** + * @example logins + */ + type: string; + /** + * The ID of the user associated with this login + * + * @example 1234 + */ + id: string; + /** + * JWT token for use in future authenticated requests to the API. + * + * @example + */ + token: string; +}; + +export type LoginRequest = { + emailAddress: string; + password: string; +}; From c1abccaa4cde74fb3f0d9301751d502b4acc548d Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Tue, 17 Sep 2024 17:05:49 -0700 Subject: [PATCH 042/102] [TM-1272] Namespace the generated client side files by service. (cherry picked from commit 3d87fe6ea9fe1becf7e26ca2930edf7cde5654d3) --- openapi-codegen.config.ts | 6 +++--- package.json | 4 ++-- .../userServiceComponents.ts} | 8 ++++---- .../userServiceFetcher.ts} | 12 ++++++------ .../userServiceSchemas.ts} | 0 5 files changed, 15 insertions(+), 15 deletions(-) rename src/generated/v3/{apiV3Components.ts => userService/userServiceComponents.ts} (69%) rename src/generated/v3/{apiV3Fetcher.ts => userService/userServiceFetcher.ts} (87%) rename src/generated/v3/{apiV3Schemas.ts => userService/userServiceSchemas.ts} (100%) diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 418e0e387..3c444a153 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -37,14 +37,14 @@ export default defineConfig({ }); } }, - apiV3: { + userService: { from: { source: "url", url: `${process.env.NEXT_PUBLIC_API_BASE_URL}/user-service/api-json` }, - outputDir: "src/generated/v3", + outputDir: "src/generated/v3/userService", to: async context => { - const filenamePrefix = "apiV3"; + const filenamePrefix = "userService"; const { schemasFiles } = await generateSchemaTypes(context, { filenamePrefix }); diff --git a/package.json b/package.json index 098e52b3a..a05438818 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "postinstall": "patch-package", "dev": "next dev", - "build": "npm run generate:api && npm run generate:apiv3 && next build", + "build": "npm run generate:api && npm run generate:userService && next build", "start": "next start", "test": "jest --watch", "test:coverage": "jest --coverage", @@ -16,7 +16,7 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "generate:api": "npx openapi-codegen gen api", - "generate:apiv3": "npx openapi-codegen gen apiV3", + "generate:userService": "npx openapi-codegen gen userService", "tx:push": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli push --key-generator=hash src/ --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET", "tx:pull": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli pull --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET" }, diff --git a/src/generated/v3/apiV3Components.ts b/src/generated/v3/userService/userServiceComponents.ts similarity index 69% rename from src/generated/v3/apiV3Components.ts rename to src/generated/v3/userService/userServiceComponents.ts index 796dd52e7..a899d7028 100644 --- a/src/generated/v3/apiV3Components.ts +++ b/src/generated/v3/userService/userServiceComponents.ts @@ -3,9 +3,9 @@ * * @version 1.0 */ -import type * as Fetcher from "./apiV3Fetcher"; -import { apiV3Fetch } from "./apiV3Fetcher"; -import type * as Schemas from "./apiV3Schemas"; +import type * as Fetcher from "./userServiceFetcher"; +import { userServiceFetch } from "./userServiceFetcher"; +import type * as Schemas from "./userServiceSchemas"; export type AuthControllerLoginError = Fetcher.ErrorWrapper<{ status: 401; @@ -30,7 +30,7 @@ export type AuthControllerLoginVariables = { }; export const authControllerLogin = (variables: AuthControllerLoginVariables, signal?: AbortSignal) => - apiV3Fetch({ + userServiceFetch({ url: "/auth/v3/logins", method: "post", ...variables, diff --git a/src/generated/v3/apiV3Fetcher.ts b/src/generated/v3/userService/userServiceFetcher.ts similarity index 87% rename from src/generated/v3/apiV3Fetcher.ts rename to src/generated/v3/userService/userServiceFetcher.ts index 8ecb06f38..8822011b8 100644 --- a/src/generated/v3/apiV3Fetcher.ts +++ b/src/generated/v3/userService/userServiceFetcher.ts @@ -1,9 +1,9 @@ -export type ApiV3FetcherExtraProps = { +export type UserServiceFetcherExtraProps = { /** * You can add some extra props to your generated fetchers. * * Note: You need to re-gen after adding the first property to - * have the `ApiV3FetcherExtraProps` injected in `ApiV3Components.ts` + * have the `UserServiceFetcherExtraProps` injected in `UserServiceComponents.ts` **/ }; @@ -11,7 +11,7 @@ const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; export type ErrorWrapper = TError | { status: "unknown"; payload: string }; -export type ApiV3FetcherOptions = { +export type UserServiceFetcherOptions = { url: string; method: string; body?: TBody; @@ -19,9 +19,9 @@ export type ApiV3FetcherOptions = { queryParams?: TQueryParams; pathParams?: TPathParams; signal?: AbortSignal; -} & ApiV3FetcherExtraProps; +} & UserServiceFetcherExtraProps; -export async function apiV3Fetch< +export async function userServiceFetch< TData, TError, TBody extends {} | FormData | undefined | null, @@ -36,7 +36,7 @@ export async function apiV3Fetch< pathParams, queryParams, signal -}: ApiV3FetcherOptions): Promise { +}: UserServiceFetcherOptions): Promise { try { const requestHeaders: HeadersInit = { "Content-Type": "application/json", diff --git a/src/generated/v3/apiV3Schemas.ts b/src/generated/v3/userService/userServiceSchemas.ts similarity index 100% rename from src/generated/v3/apiV3Schemas.ts rename to src/generated/v3/userService/userServiceSchemas.ts From b17cd1f7ae877cca11e822488e8fe69e7b58a405 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 18 Sep 2024 14:09:53 -0700 Subject: [PATCH 043/102] [TM-1272] Update the service documentation path. (cherry picked from commit ec064de473466221f4f3d92e9402d3e50bbd6d84) --- openapi-codegen.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 3c444a153..44b874e20 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -40,7 +40,7 @@ export default defineConfig({ userService: { from: { source: "url", - url: `${process.env.NEXT_PUBLIC_API_BASE_URL}/user-service/api-json` + url: `${process.env.NEXT_PUBLIC_API_BASE_URL}/user-service/documentation/api-json` }, outputDir: "src/generated/v3/userService", to: async context => { From b306f1845f8dc33b0295ab03b89e21ba2b202b4c Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 19 Sep 2024 10:24:10 -0700 Subject: [PATCH 044/102] [TM-1272] API Connection system implemented. (cherry picked from commit 0a469ef106c31904f911904d2714109cbcd069dd) --- openapi-codegen.config.ts | 380 +++++++++++++++++- package.json | 14 +- src/connections/Login.ts | 47 +++ .../v3/userService/userServiceComponents.ts | 13 +- .../v3/userService/userServiceFetcher.ts | 89 ++-- .../v3/userService/userServicePredicates.ts | 8 + src/generated/v3/utils.ts | 93 +++++ src/hooks/useConnection.ts | 52 +++ src/hooks/usePrevious.ts | 14 + src/pages/_app.tsx | 10 +- src/pages/api/auth/login.tsx | 17 - src/pages/api/auth/logout.tsx | 9 - src/pages/auth/login/components/LoginForm.tsx | 2 +- src/pages/auth/login/index.page.tsx | 35 +- src/store/apiSlice.ts | 90 +++++ src/store/store.ts | 11 + src/types/connection.ts | 16 + yarn.lock | 78 +++- 18 files changed, 838 insertions(+), 140 deletions(-) create mode 100644 src/connections/Login.ts create mode 100644 src/generated/v3/userService/userServicePredicates.ts create mode 100644 src/generated/v3/utils.ts create mode 100644 src/hooks/useConnection.ts create mode 100644 src/hooks/usePrevious.ts delete mode 100644 src/pages/api/auth/login.tsx delete mode 100644 src/pages/api/auth/logout.tsx create mode 100644 src/store/apiSlice.ts create mode 100644 src/store/store.ts create mode 100644 src/types/connection.ts diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 44b874e20..78ed5ce64 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -1,8 +1,32 @@ +/* eslint-disable no-case-declarations */ import { defineConfig } from "@openapi-codegen/cli"; +import { Config } from "@openapi-codegen/cli/lib/types"; import { generateFetchers, generateReactQueryComponents, generateSchemaTypes } from "@openapi-codegen/typescript"; +import { ConfigBase, Context } from "@openapi-codegen/typescript/lib/generators/types"; +import c from "case"; import dotenv from "dotenv"; +import _ from "lodash"; +import { + ComponentsObject, + isReferenceObject, + OpenAPIObject, + OperationObject, + ParameterObject, + PathItemObject +} from "openapi3-ts"; +import ts from "typescript"; + +const f = ts.factory; + dotenv.config(); -export default defineConfig({ + +// The services defined in the v3 Node BE codebase. Although the URL path for APIs in the v3 space +// are namespaced by feature set rather than service (a service may contain multiple namespaces), we +// isolate the generated API integration by service to make it easier for a developer to find where +// the associated BE code is for a given FE API integration. +const SERVICES = ["user-service"]; + +const config: Record = { api: { from: { source: "url", @@ -13,11 +37,11 @@ export default defineConfig({ let paths = context.openAPIDocument.paths; let newPaths: any = {}; //! Treat carefully this might potentially break the api generation - // This Logic will make sure every sigle endpoint has a `operationId` key (needed to generate endpoints) - Object.keys(paths).forEach((k, i) => { + // This Logic will make sure every single endpoint has a `operationId` key (needed to generate endpoints) + Object.keys(paths).forEach(k => { newPaths[k] = {}; const eps = Object.keys(paths[k]).filter(ep => ep !== "parameters"); - eps.forEach((ep, i) => { + eps.forEach(ep => { const current = paths[k][ep]; const operationId = ep + k.replaceAll("/", "-").replaceAll("{", "").replaceAll("}", ""); newPaths[k][ep] = { @@ -36,22 +60,344 @@ export default defineConfig({ schemasFiles }); } - }, - userService: { + } +}; + +for (const service of SERVICES) { + const name = _.camelCase(service); + config[name] = { from: { source: "url", - url: `${process.env.NEXT_PUBLIC_API_BASE_URL}/user-service/documentation/api-json` + url: `${process.env.NEXT_PUBLIC_API_BASE_URL}/${service}/documentation/api-json` }, - outputDir: "src/generated/v3/userService", + outputDir: `src/generated/v3/${name}`, to: async context => { - const filenamePrefix = "userService"; - const { schemasFiles } = await generateSchemaTypes(context, { - filenamePrefix - }); - await generateFetchers(context, { - filenamePrefix, - schemasFiles - }); + const { schemasFiles } = await generateSchemaTypes(context, { filenamePrefix: name }); + await generateFetchers(context, { filenamePrefix: name, schemasFiles }); + await generatePendingPredicates(context, { filenamePrefix: name }); } + }; +} + +export default defineConfig(config); + +/** + * Generates Connection predicates for checking if a given request is in progress or failed. + * + * Based on generators from https://github.com/fabien0102/openapi-codegen/blob/main/plugins/typescript. Many of the + * methods here are similar to ones in that repo, but they aren't exported, so were copied from there and modified for + * use in this generator. + */ +const generatePendingPredicates = async (context: Context, config: ConfigBase) => { + const sourceFile = ts.createSourceFile("index.ts", "", ts.ScriptTarget.Latest); + + const printer = ts.createPrinter({ + newLine: ts.NewLineKind.LineFeed, + removeComments: false + }); + + const printNodes = (nodes: ts.Node[]) => + nodes + .map((node: ts.Node, i, nodes) => { + return ( + printer.printNode(ts.EmitHint.Unspecified, node, sourceFile) + + (ts.isJSDoc(node) || (ts.isImportDeclaration(node) && nodes[i + 1] && ts.isImportDeclaration(nodes[i + 1])) + ? "" + : "\n") + ); + }) + .join("\n"); + + const filenamePrefix = c.snake(config.filenamePrefix ?? context.openAPIDocument.info.title) + "-"; + const formatFilename = config.filenameCase ? c[config.filenameCase] : c.camel; + const filename = formatFilename(filenamePrefix + "-predicates"); + const nodes: ts.Node[] = []; + const componentImports: string[] = []; + + let variablesExtraPropsType: ts.TypeNode = f.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword); + + Object.entries(context.openAPIDocument.paths).forEach(([route, verbs]: [string, PathItemObject]) => { + Object.entries(verbs).forEach(([verb, operation]) => { + if (!isVerb(verb) || !isOperationObject(operation)) return; + + const operationId = c.camel(operation.operationId); + const { pathParamsType, variablesType, queryParamsType } = getOperationTypes({ + openAPIDocument: context.openAPIDocument, + operation, + operationId, + pathParameters: verbs.parameters, + variablesExtraPropsType + }); + + for (const type of [pathParamsType, queryParamsType, variablesType]) { + if (ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName)) { + componentImports.push(type.typeName.text); + } + } + + nodes.push( + ...createPredicateNodes({ + pathParamsType, + variablesType, + queryParamsType, + url: route, + verb, + name: operationId + }) + ); + }); + }); + + await context.writeFile( + filename + ".ts", + printNodes([ + createNamedImport(["ApiDataStore"], "@/types/connection"), + createNamedImport(["isFetching", "apiFetchFailed"], `../utils`), + ...(componentImports.length == 0 + ? [] + : [createNamedImport(componentImports, `./${formatFilename(filenamePrefix + "-components")}`)]), + ...nodes + ]) + ); +}; + +const camelizedPathParams = (url: string) => url.replace(/\{\w*}/g, match => `{${c.camel(match)}}`); + +const createPredicateNodes = ({ + queryParamsType, + pathParamsType, + variablesType, + url, + verb, + name +}: { + pathParamsType: ts.TypeNode; + queryParamsType: ts.TypeNode; + variablesType: ts.TypeNode; + url: string; + verb: string; + name: string; +}) => { + const nodes: ts.Node[] = []; + + const stateTypeDeclaration = f.createParameterDeclaration( + undefined, + undefined, + f.createIdentifier("state"), + undefined, + f.createTypeReferenceNode("ApiDataStore"), + undefined + ); + + nodes.push( + ...["isFetching", "apiFetchFailed"].map(fnName => + f.createVariableStatement( + [f.createModifier(ts.SyntaxKind.ExportKeyword)], + f.createVariableDeclarationList( + [ + f.createVariableDeclaration( + f.createIdentifier(`${name}${_.upperFirst(fnName)}`), + undefined, + undefined, + f.createArrowFunction( + undefined, + undefined, + variablesType.kind !== ts.SyntaxKind.VoidKeyword + ? [ + stateTypeDeclaration, + f.createParameterDeclaration( + undefined, + undefined, + f.createIdentifier("variables"), + undefined, + variablesType, + undefined + ) + ] + : [stateTypeDeclaration], + undefined, + f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + f.createCallExpression( + f.createIdentifier(fnName), + [queryParamsType, pathParamsType], + [ + f.createObjectLiteralExpression( + [ + f.createShorthandPropertyAssignment("state"), + f.createPropertyAssignment( + f.createIdentifier("url"), + f.createStringLiteral(camelizedPathParams(url)) + ), + f.createPropertyAssignment(f.createIdentifier("method"), f.createStringLiteral(verb)), + ...(variablesType.kind !== ts.SyntaxKind.VoidKeyword + ? [f.createSpreadAssignment(f.createIdentifier("variables"))] + : []) + ], + false + ) + ] + ) + ) + ) + ], + ts.NodeFlags.Const + ) + ) + ) + ); + + return nodes; +}; + +const isVerb = (verb: string): verb is "get" | "post" | "patch" | "put" | "delete" => + ["get", "post", "patch", "put", "delete"].includes(verb); + +const isOperationObject = (obj: any): obj is OperationObject & { operationId: string } => + typeof obj === "object" && typeof (obj as any).operationId === "string"; + +export type GetOperationTypesOptions = { + operationId: string; + operation: OperationObject; + openAPIDocument: OpenAPIObject; + pathParameters?: PathItemObject["parameters"]; + variablesExtraPropsType: ts.TypeNode; +}; + +export type GetOperationTypesOutput = { + pathParamsType: ts.TypeNode; + variablesType: ts.TypeNode; + queryParamsType: ts.TypeNode; +}; + +const getParamsGroupByType = (parameters: OperationObject["parameters"] = [], components: ComponentsObject = {}) => { + const { query: queryParams = [] as ParameterObject[], path: pathParams = [] as ParameterObject[] } = _.groupBy( + [...parameters].map(p => { + if (isReferenceObject(p)) { + const schema = _.get(components, p.$ref.replace("#/components/", "").replace("/", ".")); + if (!schema) { + throw new Error(`${p.$ref} not found!`); + } + return schema; + } else { + return p; + } + }), + "in" + ); + + return { queryParams, pathParams }; +}; + +export const getVariablesType = ({ + pathParamsType, + pathParamsOptional, + queryParamsType, + queryParamsOptional +}: { + pathParamsType: ts.TypeNode; + pathParamsOptional: boolean; + queryParamsType: ts.TypeNode; + queryParamsOptional: boolean; +}) => { + const variablesItems: ts.TypeElement[] = []; + + const hasProperties = (node: ts.Node) => { + return (!ts.isTypeLiteralNode(node) || node.members.length > 0) && node.kind !== ts.SyntaxKind.UndefinedKeyword; + }; + + if (hasProperties(pathParamsType)) { + variablesItems.push( + f.createPropertySignature( + undefined, + f.createIdentifier("pathParams"), + pathParamsOptional ? f.createToken(ts.SyntaxKind.QuestionToken) : undefined, + pathParamsType + ) + ); + } + if (hasProperties(queryParamsType)) { + variablesItems.push( + f.createPropertySignature( + undefined, + f.createIdentifier("queryParams"), + queryParamsOptional ? f.createToken(ts.SyntaxKind.QuestionToken) : undefined, + queryParamsType + ) + ); + } + + return variablesItems.length === 0 + ? f.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword) + : f.createTypeLiteralNode(variablesItems); +}; + +const getOperationTypes = ({ + operationId, + operation, + openAPIDocument, + pathParameters = [], + variablesExtraPropsType +}: GetOperationTypesOptions): GetOperationTypesOutput => { + // Generate params types + const { pathParams, queryParams } = getParamsGroupByType( + [...pathParameters, ...(operation.parameters || [])], + openAPIDocument.components + ); + + const pathParamsOptional = pathParams.reduce((mem, p) => { + return mem && !p.required; + }, true); + const queryParamsOptional = queryParams.reduce((mem, p) => { + return mem && !p.required; + }, true); + + const pathParamsType = + pathParams.length > 0 + ? f.createTypeReferenceNode(`${c.pascal(operationId)}PathParams`) + : f.createTypeLiteralNode([]); + + const queryParamsType = + queryParams.length > 0 + ? f.createTypeReferenceNode(`${c.pascal(operationId)}QueryParams`) + : f.createTypeLiteralNode([]); + + const variablesIdentifier = c.pascal(`${operationId}Variables`); + + let variablesType: ts.TypeNode = getVariablesType({ + pathParamsType, + queryParamsType, + pathParamsOptional, + queryParamsOptional + }); + + if (variablesExtraPropsType.kind !== ts.SyntaxKind.VoidKeyword) { + variablesType = + variablesType.kind === ts.SyntaxKind.VoidKeyword + ? variablesExtraPropsType + : f.createIntersectionTypeNode([variablesType, variablesExtraPropsType]); + } + + if (variablesType.kind !== ts.SyntaxKind.VoidKeyword) { + variablesType = f.createTypeReferenceNode(variablesIdentifier); } -}); + + return { + pathParamsType, + queryParamsType, + variablesType + }; +}; + +const createNamedImport = (fnName: string | string[], filename: string, isTypeOnly = false) => { + const fnNames = Array.isArray(fnName) ? fnName : [fnName]; + return f.createImportDeclaration( + undefined, + f.createImportClause( + isTypeOnly, + undefined, + f.createNamedImports(fnNames.map(name => f.createImportSpecifier(false, undefined, f.createIdentifier(name)))) + ), + f.createStringLiteral(filename), + undefined + ); +}; diff --git a/package.json b/package.json index a05438818..c982b22b7 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "postinstall": "patch-package", "dev": "next dev", - "build": "npm run generate:api && npm run generate:userService && next build", + "build": "npm run generate:api && npm run generate:services && next build", "start": "next start", "test": "jest --watch", "test:coverage": "jest --coverage", @@ -15,8 +15,9 @@ "prepare": "husky install", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", - "generate:api": "npx openapi-codegen gen api", - "generate:userService": "npx openapi-codegen gen userService", + "generate:api": "openapi-codegen gen api", + "generate:userService": "openapi-codegen gen userService", + "generate:services": "npm run generate:userService", "tx:push": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli push --key-generator=hash src/ --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET", "tx:pull": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli pull --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET" }, @@ -31,6 +32,7 @@ "@mui/icons-material": "^5.11.0", "@mui/material": "^5.11.7", "@mui/x-data-grid": "^6.16.1", + "@reduxjs/toolkit": "^2.2.7", "@sentry/nextjs": "^7.109.0", "@tailwindcss/forms": "^0.5.3", "@tanstack/react-query": "^4.23.0", @@ -42,6 +44,7 @@ "@transifex/react": "^5.0.6", "@turf/bbox": "^6.5.0", "canvg": "^4.0.1", + "case": "^1.6.3", "circle-to-polygon": "^2.2.0", "classnames": "^2.3.2", "date-fns": "^2.29.3", @@ -69,7 +72,10 @@ "react-if": "^4.1.4", "react-inlinesvg": "^3.0.0", "react-joyride": "^2.5.5", + "react-redux": "^9.1.2", + "redux-logger": "^3.0.6", "recharts": "^2.13.0", + "reselect": "^4.1.8", "swiper": "^9.0.5", "tailwind-merge": "^1.14.0", "typescript": "4.9.4", @@ -101,9 +107,11 @@ "@types/mapbox-gl": "^2.7.13", "@types/mapbox__mapbox-gl-draw": "^1.4.1", "@types/node": "18.11.18", + "@types/pluralize": "^0.0.33", "@types/react": "18.0.27", "@types/react-dom": "18.0.10", "@types/react-test-renderer": "^18.0.0", + "@types/redux-logger": "^3.0.13", "@types/uuid": "^9.0.8", "@typescript-eslint/eslint-plugin": "^5.10.2", "@typescript-eslint/parser": "^5.10.2", diff --git a/src/connections/Login.ts b/src/connections/Login.ts new file mode 100644 index 000000000..5b551150d --- /dev/null +++ b/src/connections/Login.ts @@ -0,0 +1,47 @@ +import { createSelector } from "reselect"; + +import { authLogin } from "@/generated/v3/userService/userServiceComponents"; +import { authLoginFetchFailed, authLoginIsFetching } from "@/generated/v3/userService/userServicePredicates"; +import { Connection } from "@/types/connection"; + +type LoginConnection = { + isLoggingIn: boolean; + isLoggedIn: boolean; + loginFailed: boolean; + login: (emailAddress: string, password: string) => void; +}; + +const login = (emailAddress: string, password: string) => authLogin({ body: { emailAddress, password } }); + +export const loginConnection: Connection = { + selector: createSelector([authLoginIsFetching, authLoginFetchFailed], (isLoggingIn, failedLogin) => { + return { + isLoggingIn, + // TODO get from auth token + isLoggedIn: false, + loginFailed: failedLogin != null, + + login + }; + }) + + // selector(state: ApiDataStore): LoginConnection { + // const values = Object.values(state.logins); + // if (values.length > 1) { + // console.error("More than one Login recorded in the store!", state.logins); + // } + // + // // TODO We don't actually want the token to be part of the shape in this case, or to come from + // // the store. The token should always be fetched from local storage so that logins persist. + // const authToken = values[0]?.token; + // return { + // authToken, + // isLoggingIn: authLoginIsFetching(state), + // isLoggedIn: authToken != null, + // loginFailed: authLoginFetchFailed(state) != null, + // login: (emailAddress: string, password: string) => { + // authLogin({ body: { emailAddress, password } }); + // } + // }; + // } +}; diff --git a/src/generated/v3/userService/userServiceComponents.ts b/src/generated/v3/userService/userServiceComponents.ts index a899d7028..cd96b66da 100644 --- a/src/generated/v3/userService/userServiceComponents.ts +++ b/src/generated/v3/userService/userServiceComponents.ts @@ -7,7 +7,7 @@ import type * as Fetcher from "./userServiceFetcher"; import { userServiceFetch } from "./userServiceFetcher"; import type * as Schemas from "./userServiceSchemas"; -export type AuthControllerLoginError = Fetcher.ErrorWrapper<{ +export type AuthLoginError = Fetcher.ErrorWrapper<{ status: 401; payload: { /** @@ -21,16 +21,19 @@ export type AuthControllerLoginError = Fetcher.ErrorWrapper<{ }; }>; -export type AuthControllerLoginResponse = { +export type AuthLoginResponse = { data?: Schemas.LoginResponse; }; -export type AuthControllerLoginVariables = { +export type AuthLoginVariables = { body: Schemas.LoginRequest; }; -export const authControllerLogin = (variables: AuthControllerLoginVariables, signal?: AbortSignal) => - userServiceFetch({ +/** + * Receive a JWT Token in exchange for login credentials + */ +export const authLogin = (variables: AuthLoginVariables, signal?: AbortSignal) => + userServiceFetch({ url: "/auth/v3/logins", method: "post", ...variables, diff --git a/src/generated/v3/userService/userServiceFetcher.ts b/src/generated/v3/userService/userServiceFetcher.ts index 8822011b8..46bfad6e4 100644 --- a/src/generated/v3/userService/userServiceFetcher.ts +++ b/src/generated/v3/userService/userServiceFetcher.ts @@ -1,3 +1,9 @@ +import { dispatchRequest, resolveUrl } from "@/generated/v3/utils"; + +// This type is imported in the auto generated `userServiceComponents` file, so it needs to be +// exported from this file. +export type { ErrorWrapper } from "../utils"; + export type UserServiceFetcherExtraProps = { /** * You can add some extra props to your generated fetchers. @@ -7,10 +13,6 @@ export type UserServiceFetcherExtraProps = { **/ }; -const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; - -export type ErrorWrapper = TError | { status: "unknown"; payload: string }; - export type UserServiceFetcherOptions = { url: string; method: string; @@ -21,7 +23,7 @@ export type UserServiceFetcherOptions): Promise { - try { - const requestHeaders: HeadersInit = { - "Content-Type": "application/json", - ...headers - }; - - /** - * As the fetch API is being used, when multipart/form-data is specified - * the Content-Type header must be deleted so that the browser can set - * the correct boundary. - * https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object - */ - if (requestHeaders["Content-Type"].toLowerCase().includes("multipart/form-data")) { - delete requestHeaders["Content-Type"]; - } - - const response = await window.fetch(`${baseUrl}${resolveUrl(url, queryParams, pathParams)}`, { - signal, - method: method.toUpperCase(), - body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined, - headers: requestHeaders - }); - if (!response.ok) { - let error: ErrorWrapper; - try { - error = await response.json(); - } catch (e) { - error = { - status: "unknown" as const, - payload: e instanceof Error ? `Unexpected error (${e.message})` : "Unexpected error" - }; - } +}: UserServiceFetcherOptions) { + const requestHeaders: HeadersInit = { + "Content-Type": "application/json", + ...headers + }; - throw error; - } - - if (response.headers.get("content-type")?.includes("json")) { - return await response.json(); - } else { - // if it is not a json response, assume it is a blob and cast it to TData - return (await response.blob()) as unknown as TData; - } - } catch (e) { - let errorObject: Error = { - name: "unknown" as const, - message: e instanceof Error ? `Network error (${e.message})` : "Network error", - stack: e as string - }; - throw errorObject; + /** + * As the fetch API is being used, when multipart/form-data is specified + * the Content-Type header must be deleted so that the browser can set + * the correct boundary. + * https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object + */ + if (requestHeaders["Content-Type"].toLowerCase().includes("multipart/form-data")) { + delete requestHeaders["Content-Type"]; } -} -const resolveUrl = (url: string, queryParams: Record = {}, pathParams: Record = {}) => { - let query = new URLSearchParams(queryParams).toString(); - if (query) query = `?${query}`; - return url.replace(/\{\w*\}/g, key => pathParams[key.slice(1, -1)]) + query; -}; + // The promise is ignored on purpose. Further progress of the request is tracked through + // redux. + dispatchRequest(resolveUrl(url, queryParams, pathParams), { + signal, + method: method.toUpperCase(), + body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined, + headers: requestHeaders + }); +} diff --git a/src/generated/v3/userService/userServicePredicates.ts b/src/generated/v3/userService/userServicePredicates.ts new file mode 100644 index 000000000..a79ea180b --- /dev/null +++ b/src/generated/v3/userService/userServicePredicates.ts @@ -0,0 +1,8 @@ +import { isFetching, fetchFailed } from "../utils"; +import { ApiDataStore } from "@/store/apiSlice"; + +export const authLoginIsFetching = (state: ApiDataStore) => + isFetching<{}, {}>({ state, url: "/auth/v3/logins", method: "post" }); + +export const authLoginFetchFailed = (state: ApiDataStore) => + fetchFailed<{}, {}>({ state, url: "/auth/v3/logins", method: "post" }); diff --git a/src/generated/v3/utils.ts b/src/generated/v3/utils.ts new file mode 100644 index 000000000..20274c12f --- /dev/null +++ b/src/generated/v3/utils.ts @@ -0,0 +1,93 @@ +import { + ApiDataStore, + apiFetchFailed, + apiFetchStarting, + apiFetchSucceeded, + isErrorState, + isInProgress, + Method, + PendingErrorState +} from "@/store/apiSlice"; +import store from "@/store/store"; + +const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; + +export type ErrorWrapper = TError | { statusCode: -1; message: string }; + +type SelectorOptions = { + state: ApiDataStore; + url: string; + method: string; + queryParams?: TQueryParams; + pathParams?: TPathParams; +}; + +export const resolveUrl = ( + url: string, + queryParams: Record = {}, + pathParams: Record = {} +) => { + const searchParams = new URLSearchParams(queryParams); + // Make sure the output string always ends up in the same order because we need the URL string + // that is generated from a set of query / path params to be consistent even if the order of the + // params in the source object changes. + searchParams.sort(); + let query = searchParams.toString(); + if (query) query = `?${query}`; + return `${baseUrl}${url.replace(/\{\w*}/g, key => pathParams[key.slice(1, -1)]) + query}`; +}; + +export function isFetching({ + state, + url, + method, + pathParams, + queryParams +}: SelectorOptions): boolean { + const fullUrl = resolveUrl(url, queryParams, pathParams); + const pending = state.meta.pending[method.toUpperCase() as Method][fullUrl]; + return isInProgress(pending); +} + +export function fetchFailed({ + state, + url, + method, + pathParams, + queryParams +}: SelectorOptions): PendingErrorState | null { + const fullUrl = resolveUrl(url, queryParams, pathParams); + const pending = state.meta.pending[method.toUpperCase() as Method][fullUrl]; + return isErrorState(pending) ? pending : null; +} + +export async function dispatchRequest(url: string, requestInit: RequestInit) { + const actionPayload = { url, method: requestInit.method as Method }; + store.dispatch(apiFetchStarting(actionPayload)); + + try { + const response = await window.fetch(url, requestInit); + + if (!response.ok) { + const error = (await response.json()) as ErrorWrapper; + store.dispatch(apiFetchFailed({ ...actionPayload, error: error as PendingErrorState })); + return; + } + + if (!response.headers.get("content-type")?.includes("json")) { + // this API integration only supports JSON type responses at the moment. + throw new Error(`Response type is not JSON [${response.headers.get("content-type")}]`); + } + + const responsePayload = await response.json(); + if (responsePayload.statusCode != null && responsePayload.message != null) { + store.dispatch(apiFetchFailed({ ...actionPayload, error: responsePayload })); + } else { + store.dispatch(apiFetchSucceeded({ ...actionPayload, response: responsePayload })); + } + } catch (e) { + console.error("Unexpected API fetch failure", e); + const message = e instanceof Error ? `Network error (${e.message})` : "Network error"; + store.dispatch(apiFetchFailed({ ...actionPayload, error: { statusCode: -1, message } })); + } +} diff --git a/src/hooks/useConnection.ts b/src/hooks/useConnection.ts new file mode 100644 index 000000000..926fc0a8d --- /dev/null +++ b/src/hooks/useConnection.ts @@ -0,0 +1,52 @@ +import { useCallback, useEffect, useState } from "react"; + +import store from "@/store/store"; +import { Connected, Connection, OptionalProps } from "@/types/connection"; + +/** + * Use a connection to efficiently depend on data in the Redux store. + * + * In this hook, an internal subscription to the store is used instead of a useSelector() on the + * whole API state. This limits redraws of the component to the times that the connected state of + * the Connection changes. + */ +export function useConnection( + connection: Connection, + props: TProps | Record = {} +): Connected { + const { selector, isLoaded, load } = connection; + + const getConnected = useCallback(() => { + const connected = selector(store.getState().api, props); + const loadingDone = isLoaded == null || isLoaded(connected, props); + return { loadingDone, connected }; + }, [isLoaded, props, selector]); + + const [connected, setConnected] = useState(() => { + const { loadingDone, connected } = getConnected(); + return loadingDone ? connected : null; + }); + + useEffect( + () => { + function checkState() { + const { loadingDone, connected: currentConnected } = getConnected(); + if (load != null) load(currentConnected, props); + if (loadingDone) { + setConnected(currentConnected); + } else { + // In case something was loaded and then got unloaded via a redux store clear + if (connected != null) setConnected(null); + } + } + + const subscription = store.subscribe(checkState); + checkState(); + return subscription; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [connection, ...Object.keys(props ?? [])] + ); + + return [connected != null, connected ?? {}]; +} diff --git a/src/hooks/usePrevious.ts b/src/hooks/usePrevious.ts new file mode 100644 index 000000000..7dd8a1021 --- /dev/null +++ b/src/hooks/usePrevious.ts @@ -0,0 +1,14 @@ +import { useEffect, useRef } from "react"; + +export function usePrevious(value: T): T | undefined { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }, [value]); + return ref.current; +} + +export function useValueChanged(value: T, callback: () => void) { + const previous = usePrevious(value); + if (previous !== value) callback(); +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index bc2429bd4..e38523d3e 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -9,6 +9,7 @@ import dynamic from "next/dynamic"; import { useRouter } from "next/router"; import nookies from "nookies"; import { Else, If, Then } from "react-if"; +import { Provider as ReduxProvider } from "react-redux"; import Toast from "@/components/elements/Toast/Toast"; import ModalRoot from "@/components/extensive/Modal/ModalRoot"; @@ -23,6 +24,7 @@ import WrappedQueryClientProvider from "@/context/queryclient.provider"; import RouteHistoryProvider from "@/context/routeHistory.provider"; import ToastProvider from "@/context/toast.provider"; import { getServerSideTranslations, setClientSideTranslations } from "@/i18n"; +import store from "@/store/store"; import setupYup from "@/yup.locale"; const CookieBanner = dynamic(() => import("@/components/extensive/CookieBanner/CookieBanner"), { @@ -40,7 +42,7 @@ const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessT if (isAdmin) return ( - <> + @@ -53,11 +55,11 @@ const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessT - + ); else return ( - <> + @@ -92,7 +94,7 @@ const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessT - + ); }; diff --git a/src/pages/api/auth/login.tsx b/src/pages/api/auth/login.tsx deleted file mode 100644 index fc2f50a5e..000000000 --- a/src/pages/api/auth/login.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { NextApiRequest, NextApiResponse } from "next"; -import { setCookie } from "nookies"; - -export default function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== "POST") return res.send("only POST"); - - const token = req.body.token; - - setCookie({ res }, "accessToken", token, { - maxAge: 60 * 60 * 12, // 12 hours - // httpOnly: true, - secure: process.env.NODE_ENV !== "development", - path: "/" - }); - - res.status(200).json({ success: true }); -} diff --git a/src/pages/api/auth/logout.tsx b/src/pages/api/auth/logout.tsx deleted file mode 100644 index 3ccb00eda..000000000 --- a/src/pages/api/auth/logout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { NextApiRequest, NextApiResponse } from "next"; - -export default function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== "POST") return res.send("only POST"); - - res.setHeader("Set-Cookie", "accessToken=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"); - - res.status(200).json({ success: true }); -} diff --git a/src/pages/auth/login/components/LoginForm.tsx b/src/pages/auth/login/components/LoginForm.tsx index 55d8eb74b..1aaa74d65 100644 --- a/src/pages/auth/login/components/LoginForm.tsx +++ b/src/pages/auth/login/components/LoginForm.tsx @@ -11,7 +11,7 @@ import { LoginFormDataType } from "../index.page"; type LoginFormProps = { form: UseFormReturn; - handleSave: (data: LoginFormDataType) => Promise; + handleSave: (data: LoginFormDataType) => void; loading?: boolean; }; diff --git a/src/pages/auth/login/index.page.tsx b/src/pages/auth/login/index.page.tsx index 2fb230992..3dcbf4440 100644 --- a/src/pages/auth/login/index.page.tsx +++ b/src/pages/auth/login/index.page.tsx @@ -4,9 +4,12 @@ import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import * as yup from "yup"; -import { useAuthContext } from "@/context/auth.provider"; +import { loginConnection } from "@/connections/Login"; +// import { useAuthContext } from "@/context/auth.provider"; import { ToastType, useToastContext } from "@/context/toast.provider"; +import { useConnection } from "@/hooks/useConnection"; import { useSetInviteToken } from "@/hooks/useInviteToken"; +import { useValueChanged } from "@/hooks/usePrevious"; import LoginLayout from "../layout"; import LoginForm from "./components/LoginForm"; @@ -27,35 +30,25 @@ const LoginPage = () => { useSetInviteToken(); const t = useT(); const router = useRouter(); - const { login, loginLoading } = useAuthContext(); + //const { login, loginLoading } = useAuthContext(); + const [, { isLoggedIn, isLoggingIn, loginFailed, login }] = useConnection(loginConnection); const { openToast } = useToastContext(); const form = useForm({ resolver: yupResolver(LoginFormDataSchema(t)), mode: "onSubmit" }); - /** - * Form Submit Handler - * @param data LoginFormData - * @returns Log in user and redirect to homepage - */ - const handleSave = async (data: LoginFormDataType) => { - const res = (await login( - { - email_address: data.email, - password: data.password - }, - () => openToast(t("Incorrect Email or Password"), ToastType.ERROR) - )) as { success: boolean }; - - if (!res?.success) return; - - return router.push("/home"); - }; + useValueChanged(loginFailed, () => { + if (loginFailed) openToast(t("Incorrect Email or Password"), ToastType.ERROR); + }); + + const handleSave = (data: LoginFormDataType) => login(data.email, data.password); + + if (isLoggedIn) return router.push("/home"); return ( - + ); }; diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts new file mode 100644 index 000000000..d8f40f8a9 --- /dev/null +++ b/src/store/apiSlice.ts @@ -0,0 +1,90 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { isArray } from "lodash"; + +import { LoginResponse } from "@/generated/v3/userService/userServiceSchemas"; + +export type PendingErrorState = { + statusCode: number; + message: string; + error?: string; +}; + +export type Pending = true | PendingErrorState; + +export const isInProgress = (pending?: Pending) => pending === true; + +export const isErrorState = (pending?: Pending): pending is PendingErrorState => + pending != null && !isInProgress(pending); + +export type Method = "GET" | "DELETE" | "POST" | "PUT" | "PATCH"; + +export type ApiPendingStore = { + [key in Method]: Record; +}; + +// The list of potential resource types. Each of these resources must be included in ApiDataStore, +// with a mapping to the response type for that resource. +export const RESOURCES = ["logins"] as const; + +export type JsonApiResource = { + type: (typeof RESOURCES)[number]; + id: string; +}; + +export type JsonApiResponse = { + data: JsonApiResource[] | JsonApiResource; +}; + +export type ApiDataStore = { + logins: Record; + + meta: { + pending: ApiPendingStore; + }; +}; + +const initialState: ApiDataStore = { + logins: {}, + + meta: { + pending: { + GET: {}, + DELETE: {}, + POST: {}, + PUT: {}, + PATCH: {} + } + } +}; + +const apiSlice = createSlice({ + name: "api", + initialState, + reducers: { + apiFetchStarting: (state, action: PayloadAction<{ url: string; method: Method }>) => { + const { url, method } = action.payload; + state.meta.pending[method][url] = true; + }, + apiFetchFailed: (state, action: PayloadAction<{ url: string; method: Method; error: PendingErrorState }>) => { + const { url, method, error } = action.payload; + state.meta.pending[method][url] = error; + }, + apiFetchSucceeded: (state, action: PayloadAction<{ url: string; method: Method; response: JsonApiResponse }>) => { + const { url, method, response } = action.payload; + delete state.meta.pending[method][url]; + + // All response objects from the v3 api conform to JsonApiResponse + let { data } = response; + if (!isArray(data)) data = [data]; + for (const resource of data) { + // The data resource type is expected to match what is declared above in ApiDataStore, but + // there isn't a way to enforce that with TS against this dynamic data structure, so we + // use the dreaded any. + state[resource.type][resource.id] = resource as any; + } + } + } +}); + +export const { apiFetchStarting, apiFetchFailed, apiFetchSucceeded } = apiSlice.actions; +export const apiReducer = apiSlice.reducer; diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 000000000..8a97b3ccc --- /dev/null +++ b/src/store/store.ts @@ -0,0 +1,11 @@ +import { configureStore } from "@reduxjs/toolkit"; +import { logger } from "redux-logger"; + +import { apiReducer } from "@/store/apiSlice"; + +export default configureStore({ + reducer: { + api: apiReducer + }, + middleware: getDefaultMiddleware => getDefaultMiddleware().concat(logger) +}); diff --git a/src/types/connection.ts b/src/types/connection.ts new file mode 100644 index 000000000..54612872a --- /dev/null +++ b/src/types/connection.ts @@ -0,0 +1,16 @@ +import { ApiDataStore } from "@/store/apiSlice"; + +export type OptionalProps = Record | undefined; + +export type Selector = ( + state: ApiDataStore, + props: PropsType +) => SelectedType; + +export type Connection = { + selector: Selector; + isLoaded?: (selected: SelectedType, props: PropsType) => boolean; + load?: (selected: SelectedType, props: PropsType) => void; +}; + +export type Connected = readonly [boolean, SelectedType | Record]; diff --git a/yarn.lock b/yarn.lock index d7ba39a10..6860e8436 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2829,9 +2829,9 @@ typescript "4.8.2" "@openapi-codegen/typescript@^6.1.0": - version "6.1.0" - resolved "https://registry.yarnpkg.com/@openapi-codegen/typescript/-/typescript-6.1.0.tgz#66850506a89a2a2f24a45db6a7a3ea21357de758" - integrity sha512-zwCw06hhk8BFS4DMOmOCuAFU6rfWql2M4VL7RxaqEsDWopi1GLtEJpKmRNUplv3aGGq/OLdJs1F9VdSitI1W2A== + version "6.2.4" + resolved "https://registry.yarnpkg.com/@openapi-codegen/typescript/-/typescript-6.2.4.tgz#0004c450486f16e76bbef3b278bb32bebdc7aff7" + integrity sha512-wh/J7Ij/furDIYo0yD8SjUZBCHn2+cu7N4cTKJ9M/PW7jaDYHyZk1ThYmtCFAVF2f3Jobpb51+3E0Grk8nqhpA== dependencies: case "^1.6.3" lodash "^4.17.21" @@ -2878,6 +2878,16 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== +"@reduxjs/toolkit@^2.2.7": + version "2.2.7" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.2.7.tgz#199e3d10ccb39267cb5aee92c0262fd9da7fdfb2" + integrity sha512-faI3cZbSdFb8yv9dhDTmGwclW0vk0z5o1cia+kf7gCbaCwHI5e+7tP57mJUv22pNcNbeA62GSrPpfrUfdXcQ6g== + dependencies: + immer "^10.0.3" + redux "^5.0.1" + redux-thunk "^3.1.0" + reselect "^5.1.0" + "@remirror/core-constants@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@remirror/core-constants/-/core-constants-2.0.2.tgz#f05eccdc69e3a65e7d524b52548f567904a11a1a" @@ -5021,6 +5031,11 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/pluralize@^0.0.33": + version "0.0.33" + resolved "https://registry.yarnpkg.com/@types/pluralize/-/pluralize-0.0.33.tgz#8ad9018368c584d268667dd9acd5b3b806e8c82a" + integrity sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg== + "@types/prettier@^2.1.5": version "2.7.2" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.2.tgz#6c2324641cc4ba050a8c710b2b251b377581fbf0" @@ -5111,6 +5126,13 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/redux-logger@^3.0.13": + version "3.0.13" + resolved "https://registry.yarnpkg.com/@types/redux-logger/-/redux-logger-3.0.13.tgz#473e98428cdcc6dc93c908de66732bf932e36bc8" + integrity sha512-jylqZXQfMxahkuPcO8J12AKSSCQngdEWQrw7UiLUJzMBcv1r4Qg77P6mjGLjM27e5gFQDPD8vwUMJ9AyVxFSsg== + dependencies: + redux "^5.0.0" + "@types/scheduler@*": version "0.16.2" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" @@ -5166,6 +5188,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.8.tgz#bb197b9639aa1a04cf464a617fe800cccd92ad5c" integrity sha512-d0XxK3YTObnWVp6rZuev3c49+j4Lo8g4L1ZRm9z5L0xpoZycUPshHgczK5gsUMaZOstjVYYi09p5gYvUtfChYw== +"@types/use-sync-external-store@^0.0.3": + version "0.0.3" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz#b6725d5f4af24ace33b36fafd295136e75509f43" + integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== + "@types/uuid@^9.0.8": version "9.0.8" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.8.tgz#7545ba4fc3c003d6c756f651f3bf163d8f0f29ba" @@ -7345,6 +7372,11 @@ dedent@^0.7.0: resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" integrity sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA== +deep-diff@^0.3.5: + version "0.3.8" + resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-0.3.8.tgz#c01de63efb0eec9798801d40c7e0dae25b582c84" + integrity sha512-yVn6RZmHiGnxRKR9sJb3iVV2XTF1Ghh2DiWRZ3dMnGc43yUdWWF/kX6lQyk3+P84iprfWKU/8zFTrlkvtFm1ug== + deep-equal@^2.0.5: version "2.2.0" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.0.tgz#5caeace9c781028b9ff459f33b779346637c43e6" @@ -9382,6 +9414,11 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== +immer@^10.0.3: + version "10.1.1" + resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc" + integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw== + import-fresh@^3.0.0, import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" @@ -13589,6 +13626,14 @@ react-reconciler@^0.26.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-redux@^9.1.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.1.2.tgz#deba38c64c3403e9abd0c3fbeab69ffd9d8a7e4b" + integrity sha512-0OA4dhM1W48l3uzmv6B7TXPCGmokUU4p1M44DGN2/D9a1FjVPukVjER1PcPX97jIg6aUeLq1XJo1IpfbgULn0w== + dependencies: + "@types/use-sync-external-store" "^0.0.3" + use-sync-external-store "^1.0.0" + react-refresh@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046" @@ -13780,6 +13825,23 @@ redeyed@~2.1.0: dependencies: esprima "~4.0.0" +redux-logger@^3.0.6: + version "3.0.6" + resolved "https://registry.yarnpkg.com/redux-logger/-/redux-logger-3.0.6.tgz#f7555966f3098f3c88604c449cf0baf5778274bf" + integrity sha512-JoCIok7bg/XpqA1JqCqXFypuqBbQzGQySrhFzewB7ThcnysTO30l4VCst86AuB9T9tuT03MAA56Jw2PNhRSNCg== + dependencies: + deep-diff "^0.3.5" + +redux-thunk@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" + integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== + +redux@^5.0.0, redux@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b" + integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== + reftools@^1.1.9: version "1.1.9" resolved "https://registry.yarnpkg.com/reftools/-/reftools-1.1.9.tgz#e16e19f662ccd4648605312c06d34e5da3a2b77e" @@ -13938,6 +14000,11 @@ reselect@^4.1.8: resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.8.tgz#3f5dc671ea168dccdeb3e141236f69f02eaec524" integrity sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ== +reselect@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e" + integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w== + resolve-alpn@^1.2.0: version "1.2.1" resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9" @@ -15686,6 +15753,11 @@ use-resize-observer@^9.1.0: dependencies: "@juggle/resize-observer" "^3.3.1" +use-sync-external-store@^1.0.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9" + integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw== + use-sync-external-store@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" From 1bb69a2912d29087143e865832ba24274fba3a9c Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 20 Sep 2024 23:36:12 -0700 Subject: [PATCH 045/102] [TM-1272] Functional login/logout. (cherry picked from commit 321cbcde782d68be1e3253cf4f8aac15b6af271c) --- src/admin/apiProvider/authProvider.ts | 22 ++--- src/connections/Login.ts | 56 ++++++------ src/generated/v3/utils.ts | 22 ++--- src/hooks/logout.ts | 4 +- src/hooks/useConnection.ts | 6 +- src/hooks/usePrevious.ts | 9 +- src/pages/_app.tsx | 25 +++--- src/pages/auth/login/index.page.tsx | 13 ++- src/store/StoreProvider.tsx | 21 +++++ src/store/apiSlice.ts | 118 +++++++++++++++++++++----- src/store/store.ts | 35 ++++++-- 11 files changed, 218 insertions(+), 113 deletions(-) create mode 100644 src/store/StoreProvider.tsx diff --git a/src/admin/apiProvider/authProvider.ts b/src/admin/apiProvider/authProvider.ts index 3b41fb675..a61301080 100644 --- a/src/admin/apiProvider/authProvider.ts +++ b/src/admin/apiProvider/authProvider.ts @@ -1,6 +1,5 @@ import { AuthProvider } from "react-admin"; -import { isAdmin } from "@/admin/apiProvider/utils/user"; import { fetchGetAuthLogout, fetchGetAuthMe, fetchPostAuthLogin } from "@/generated/apiComponents"; import { AdminTokenStorageKey, removeAccessToken, setAccessToken } from "./utils/token"; @@ -31,15 +30,18 @@ export const authProvider: AuthProvider = { const token = localStorage.getItem(AdminTokenStorageKey); if (!token) return Promise.reject(); - return new Promise((resolve, reject) => { - fetchGetAuthMe({}) - .then(res => { - //@ts-ignore - if (isAdmin(res.data.role)) resolve(); - else reject("Only admins are allowed."); - }) - .catch(() => reject()); - }); + // TODO (TM-1312) Once we have a connection for the users/me object, we can check the cached + // value without re-fetching on every navigation in the admin UI. The previous implementation + // is included below for reference until that ticket is complete. + // return new Promise((resolve, reject) => { + // fetchGetAuthMe({}) + // .then(res => { + // //@ts-ignore + // if (isAdmin(res.data.role)) resolve(); + // else reject("Only admins are allowed."); + // }) + // .catch(() => reject()); + // }); }, // remove local credentials and notify the auth server that the user logged out logout: async () => { diff --git a/src/connections/Login.ts b/src/connections/Login.ts index 5b551150d..16ddeface 100644 --- a/src/connections/Login.ts +++ b/src/connections/Login.ts @@ -1,47 +1,39 @@ import { createSelector } from "reselect"; +import { removeAccessToken } from "@/admin/apiProvider/utils/token"; import { authLogin } from "@/generated/v3/userService/userServiceComponents"; import { authLoginFetchFailed, authLoginIsFetching } from "@/generated/v3/userService/userServicePredicates"; +import ApiSlice, { ApiDataStore } from "@/store/apiSlice"; import { Connection } from "@/types/connection"; type LoginConnection = { isLoggingIn: boolean; isLoggedIn: boolean; loginFailed: boolean; - login: (emailAddress: string, password: string) => void; + token?: string; }; -const login = (emailAddress: string, password: string) => authLogin({ body: { emailAddress, password } }); - -export const loginConnection: Connection = { - selector: createSelector([authLoginIsFetching, authLoginFetchFailed], (isLoggingIn, failedLogin) => { - return { - isLoggingIn, - // TODO get from auth token - isLoggedIn: false, - loginFailed: failedLogin != null, +export const login = (emailAddress: string, password: string) => authLogin({ body: { emailAddress, password } }); +export const logout = () => { + removeAccessToken(); + ApiSlice.clearApiCache(); +}; - login - }; - }) +const selectFirstLogin = (state: ApiDataStore) => { + const values = Object.values(state.logins); + return values.length < 1 ? null : values[0]; +}; - // selector(state: ApiDataStore): LoginConnection { - // const values = Object.values(state.logins); - // if (values.length > 1) { - // console.error("More than one Login recorded in the store!", state.logins); - // } - // - // // TODO We don't actually want the token to be part of the shape in this case, or to come from - // // the store. The token should always be fetched from local storage so that logins persist. - // const authToken = values[0]?.token; - // return { - // authToken, - // isLoggingIn: authLoginIsFetching(state), - // isLoggedIn: authToken != null, - // loginFailed: authLoginFetchFailed(state) != null, - // login: (emailAddress: string, password: string) => { - // authLogin({ body: { emailAddress, password } }); - // } - // }; - // } +export const loginConnection: Connection = { + selector: createSelector( + [authLoginIsFetching, authLoginFetchFailed, selectFirstLogin], + (isLoggingIn, failedLogin, firstLogin) => { + return { + isLoggingIn, + isLoggedIn: firstLogin != null, + loginFailed: failedLogin != null, + token: firstLogin?.token + }; + } + ) }; diff --git a/src/generated/v3/utils.ts b/src/generated/v3/utils.ts index 20274c12f..87b3f8ea8 100644 --- a/src/generated/v3/utils.ts +++ b/src/generated/v3/utils.ts @@ -1,14 +1,4 @@ -import { - ApiDataStore, - apiFetchFailed, - apiFetchStarting, - apiFetchSucceeded, - isErrorState, - isInProgress, - Method, - PendingErrorState -} from "@/store/apiSlice"; -import store from "@/store/store"; +import ApiSlice, { ApiDataStore, isErrorState, isInProgress, Method, PendingErrorState } from "@/store/apiSlice"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; @@ -63,14 +53,14 @@ export function fetchFailed({ export async function dispatchRequest(url: string, requestInit: RequestInit) { const actionPayload = { url, method: requestInit.method as Method }; - store.dispatch(apiFetchStarting(actionPayload)); + ApiSlice.fetchStarting(actionPayload); try { const response = await window.fetch(url, requestInit); if (!response.ok) { const error = (await response.json()) as ErrorWrapper; - store.dispatch(apiFetchFailed({ ...actionPayload, error: error as PendingErrorState })); + ApiSlice.fetchFailed({ ...actionPayload, error: error as PendingErrorState }); return; } @@ -81,13 +71,13 @@ export async function dispatchRequest(url: string, requestInit: R const responsePayload = await response.json(); if (responsePayload.statusCode != null && responsePayload.message != null) { - store.dispatch(apiFetchFailed({ ...actionPayload, error: responsePayload })); + ApiSlice.fetchFailed({ ...actionPayload, error: responsePayload }); } else { - store.dispatch(apiFetchSucceeded({ ...actionPayload, response: responsePayload })); + ApiSlice.fetchSucceeded({ ...actionPayload, response: responsePayload }); } } catch (e) { console.error("Unexpected API fetch failure", e); const message = e instanceof Error ? `Network error (${e.message})` : "Network error"; - store.dispatch(apiFetchFailed({ ...actionPayload, error: { statusCode: -1, message } })); + ApiSlice.fetchFailed({ ...actionPayload, error: { statusCode: -1, message } }); } } diff --git a/src/hooks/logout.ts b/src/hooks/logout.ts index ec9834f37..b2938d82a 100644 --- a/src/hooks/logout.ts +++ b/src/hooks/logout.ts @@ -1,7 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/router"; -import { removeAccessToken } from "@/admin/apiProvider/utils/token"; +import { logout } from "@/connections/Login"; export const useLogout = () => { const queryClient = useQueryClient(); @@ -10,7 +10,7 @@ export const useLogout = () => { return () => { queryClient.getQueryCache().clear(); queryClient.clear(); - removeAccessToken(); + logout(); router.push("/"); window.location.replace("/"); }; diff --git a/src/hooks/useConnection.ts b/src/hooks/useConnection.ts index 926fc0a8d..e03552671 100644 --- a/src/hooks/useConnection.ts +++ b/src/hooks/useConnection.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; -import store from "@/store/store"; +import ApiSlice from "@/store/apiSlice"; import { Connected, Connection, OptionalProps } from "@/types/connection"; /** @@ -17,7 +17,7 @@ export function useConnection { - const connected = selector(store.getState().api, props); + const connected = selector(ApiSlice.store.getState().api, props); const loadingDone = isLoaded == null || isLoaded(connected, props); return { loadingDone, connected }; }, [isLoaded, props, selector]); @@ -40,7 +40,7 @@ export function useConnection(value: T): T | undefined { } export function useValueChanged(value: T, callback: () => void) { - const previous = usePrevious(value); - if (previous !== value) callback(); + const ref = useRef(); + useEffect(() => { + if (ref.current !== value) { + ref.current = value; + callback(); + } + }); } diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index e38523d3e..8fa0089bd 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -9,7 +9,6 @@ import dynamic from "next/dynamic"; import { useRouter } from "next/router"; import nookies from "nookies"; import { Else, If, Then } from "react-if"; -import { Provider as ReduxProvider } from "react-redux"; import Toast from "@/components/elements/Toast/Toast"; import ModalRoot from "@/components/extensive/Modal/ModalRoot"; @@ -24,14 +23,14 @@ import WrappedQueryClientProvider from "@/context/queryclient.provider"; import RouteHistoryProvider from "@/context/routeHistory.provider"; import ToastProvider from "@/context/toast.provider"; import { getServerSideTranslations, setClientSideTranslations } from "@/i18n"; -import store from "@/store/store"; +import StoreProvider from "@/store/StoreProvider"; import setupYup from "@/yup.locale"; const CookieBanner = dynamic(() => import("@/components/extensive/CookieBanner/CookieBanner"), { ssr: false }); -const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessToken?: string; props: any }) => { +const _App = ({ Component, pageProps, props, authToken }: AppProps & { authToken?: string; props: any }) => { const t = useT(); const router = useRouter(); const isAdmin = router.asPath.includes("/admin"); @@ -42,9 +41,9 @@ const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessT if (isAdmin) return ( - + - + @@ -55,15 +54,15 @@ const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessT - + ); else return ( - + - + @@ -74,12 +73,12 @@ const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessT - + - - + + @@ -94,7 +93,7 @@ const _App = ({ Component, pageProps, props, accessToken }: AppProps & { accessT - + ); }; @@ -107,7 +106,7 @@ _App.getInitialProps = async (context: AppContext) => { } catch (err) { console.log("Failed to get Serverside Transifex", err); } - return { ...ctx, props: { ...translationsData }, accessToken: cookies.accessToken }; + return { ...ctx, props: { ...translationsData }, authToken: cookies.accessToken }; }; export default _App; diff --git a/src/pages/auth/login/index.page.tsx b/src/pages/auth/login/index.page.tsx index 3dcbf4440..cd63ea567 100644 --- a/src/pages/auth/login/index.page.tsx +++ b/src/pages/auth/login/index.page.tsx @@ -4,8 +4,7 @@ import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import * as yup from "yup"; -import { loginConnection } from "@/connections/Login"; -// import { useAuthContext } from "@/context/auth.provider"; +import { login, loginConnection } from "@/connections/Login"; import { ToastType, useToastContext } from "@/context/toast.provider"; import { useConnection } from "@/hooks/useConnection"; import { useSetInviteToken } from "@/hooks/useInviteToken"; @@ -30,8 +29,7 @@ const LoginPage = () => { useSetInviteToken(); const t = useT(); const router = useRouter(); - //const { login, loginLoading } = useAuthContext(); - const [, { isLoggedIn, isLoggingIn, loginFailed, login }] = useConnection(loginConnection); + const [, { isLoggedIn, isLoggingIn, loginFailed }] = useConnection(loginConnection); const { openToast } = useToastContext(); const form = useForm({ resolver: yupResolver(LoginFormDataSchema(t)), @@ -41,14 +39,15 @@ const LoginPage = () => { useValueChanged(loginFailed, () => { if (loginFailed) openToast(t("Incorrect Email or Password"), ToastType.ERROR); }); + useValueChanged(isLoggedIn, () => { + if (isLoggedIn) router.push("/home"); + }); const handleSave = (data: LoginFormDataType) => login(data.email, data.password); - if (isLoggedIn) return router.push("/home"); - return ( - + ); }; diff --git a/src/store/StoreProvider.tsx b/src/store/StoreProvider.tsx new file mode 100644 index 000000000..dbd55e95f --- /dev/null +++ b/src/store/StoreProvider.tsx @@ -0,0 +1,21 @@ +"use client"; +import { useRef } from "react"; +import { Provider } from "react-redux"; + +import { AppStore, makeStore } from "./store"; + +export default function StoreProvider({ + authToken = undefined, + children +}: { + authToken?: string; + children: React.ReactNode; +}) { + const storeRef = useRef(); + if (!storeRef.current) { + // Create the store instance the first time this renders + storeRef.current = makeStore(authToken); + } + + return {children}; +} diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index d8f40f8a9..f11b36c83 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -1,6 +1,8 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { createListenerMiddleware, createSlice, PayloadAction } from "@reduxjs/toolkit"; import { isArray } from "lodash"; +import { Store } from "redux"; +import { setAccessToken } from "@/admin/apiProvider/utils/token"; import { LoginResponse } from "@/generated/v3/userService/userServiceSchemas"; export type PendingErrorState = { @@ -16,14 +18,15 @@ export const isInProgress = (pending?: Pending) => pending === true; export const isErrorState = (pending?: Pending): pending is PendingErrorState => pending != null && !isInProgress(pending); -export type Method = "GET" | "DELETE" | "POST" | "PUT" | "PATCH"; +const METHODS = ["GET", "DELETE", "POST", "PUT", "PATCH"] as const; +export type Method = (typeof METHODS)[number]; export type ApiPendingStore = { [key in Method]: Record; }; -// The list of potential resource types. Each of these resources must be included in ApiDataStore, -// with a mapping to the response type for that resource. +// The list of potential resource types. IMPORTANT: When a new resource type is integrated, it must +// be added to this list. export const RESOURCES = ["logins"] as const; export type JsonApiResource = { @@ -35,41 +38,54 @@ export type JsonApiResponse = { data: JsonApiResource[] | JsonApiResource; }; -export type ApiDataStore = { +type ApiResources = { logins: Record; +}; +export type ApiDataStore = ApiResources & { meta: { pending: ApiPendingStore; }; }; -const initialState: ApiDataStore = { - logins: {}, +const initialState = { + ...RESOURCES.reduce((acc: Partial, resource) => { + acc[resource] = {}; + return acc; + }, {}), meta: { - pending: { - GET: {}, - DELETE: {}, - POST: {}, - PUT: {}, - PATCH: {} - } + pending: METHODS.reduce((acc: Partial, method) => { + acc[method] = {}; + return acc; + }, {}) as ApiPendingStore } +} as ApiDataStore; + +type ApiFetchStartingProps = { + url: string; + method: Method; +}; +type ApiFetchFailedProps = ApiFetchStartingProps & { + error: PendingErrorState; +}; +type ApiFetchSucceededProps = ApiFetchStartingProps & { + response: JsonApiResponse; }; -const apiSlice = createSlice({ +export const apiSlice = createSlice({ name: "api", initialState, reducers: { - apiFetchStarting: (state, action: PayloadAction<{ url: string; method: Method }>) => { + apiFetchStarting: (state, action: PayloadAction) => { const { url, method } = action.payload; state.meta.pending[method][url] = true; }, - apiFetchFailed: (state, action: PayloadAction<{ url: string; method: Method; error: PendingErrorState }>) => { + apiFetchFailed: (state, action: PayloadAction) => { const { url, method, error } = action.payload; state.meta.pending[method][url] = error; }, - apiFetchSucceeded: (state, action: PayloadAction<{ url: string; method: Method; response: JsonApiResponse }>) => { + apiFetchSucceeded: (state, action: PayloadAction) => { const { url, method, response } = action.payload; delete state.meta.pending[method][url]; @@ -82,9 +98,71 @@ const apiSlice = createSlice({ // use the dreaded any. state[resource.type][resource.id] = resource as any; } + }, + + clearApiCache: state => { + for (const resource of RESOURCES) { + state[resource] = {}; + } + + for (const method of METHODS) { + state.meta.pending[method] = {}; + } + }, + + // only used during app bootup. + setInitialAuthToken: (state, action: PayloadAction<{ authToken: string }>) => { + const { authToken } = action.payload; + // We only ever expect there to be at most one Login in the store, and we never inspect the ID + // so we can safely fake a login into the store when we have an authToken already set in a + // cookie on app bootup. + state.logins["1"] = { id: "id", type: "logins", token: authToken }; } } }); -export const { apiFetchStarting, apiFetchFailed, apiFetchSucceeded } = apiSlice.actions; -export const apiReducer = apiSlice.reducer; +export const authListenerMiddleware = createListenerMiddleware(); +authListenerMiddleware.startListening({ + actionCreator: apiSlice.actions.apiFetchSucceeded, + effect: async ( + action: PayloadAction<{ + url: string; + method: Method; + response: JsonApiResponse; + }> + ) => { + const { url, method, response } = action.payload; + if (!url.endsWith("auth/v3/logins") || method !== "POST") return; + + const { data } = response as { data: LoginResponse }; + setAccessToken(data.token); + } +}); + +export default class ApiSlice { + private static _store: Store; + + static set store(store: Store) { + this._store = store; + } + + static get store(): Store { + return this._store; + } + + static fetchStarting(props: ApiFetchStartingProps) { + this.store.dispatch(apiSlice.actions.apiFetchStarting(props)); + } + + static fetchFailed(props: ApiFetchFailedProps) { + this.store.dispatch(apiSlice.actions.apiFetchFailed(props)); + } + + static fetchSucceeded(props: ApiFetchSucceededProps) { + this.store.dispatch(apiSlice.actions.apiFetchSucceeded(props)); + } + + static clearApiCache() { + this.store.dispatch(apiSlice.actions.clearApiCache()); + } +} diff --git a/src/store/store.ts b/src/store/store.ts index 8a97b3ccc..5b8da3850 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,11 +1,30 @@ import { configureStore } from "@reduxjs/toolkit"; import { logger } from "redux-logger"; -import { apiReducer } from "@/store/apiSlice"; - -export default configureStore({ - reducer: { - api: apiReducer - }, - middleware: getDefaultMiddleware => getDefaultMiddleware().concat(logger) -}); +import ApiSlice, { apiSlice, authListenerMiddleware } from "@/store/apiSlice"; + +export const makeStore = (authToken?: string) => { + const store = configureStore({ + reducer: { + api: apiSlice.reducer + }, + middleware: getDefaultMiddleware => { + if (process.env.NODE_ENV === "production") { + return getDefaultMiddleware().prepend(authListenerMiddleware.middleware); + } else { + return getDefaultMiddleware().prepend(authListenerMiddleware.middleware).concat(logger); + } + } + }); + + if (authToken != null) { + store.dispatch(apiSlice.actions.setInitialAuthToken({ authToken })); + } + + ApiSlice.store = store; + + return store; +}; + +// Infer the type of makeStore +export type AppStore = ReturnType; From c7f09bd8cc2e766da9f40a3af59f1d399325d28a Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 20 Sep 2024 23:38:54 -0700 Subject: [PATCH 046/102] [TM-1272] Rename hook. (cherry picked from commit 0624850c6527aed0d2a894d9146e344a7f895432) --- src/hooks/usePrevious.ts | 19 ------------------- src/hooks/useValueChanged.ts | 29 +++++++++++++++++++++++++++++ src/pages/auth/login/index.page.tsx | 2 +- 3 files changed, 30 insertions(+), 20 deletions(-) delete mode 100644 src/hooks/usePrevious.ts create mode 100644 src/hooks/useValueChanged.ts diff --git a/src/hooks/usePrevious.ts b/src/hooks/usePrevious.ts deleted file mode 100644 index 4ee40b4e5..000000000 --- a/src/hooks/usePrevious.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useEffect, useRef } from "react"; - -export function usePrevious(value: T): T | undefined { - const ref = useRef(); - useEffect(() => { - ref.current = value; - }, [value]); - return ref.current; -} - -export function useValueChanged(value: T, callback: () => void) { - const ref = useRef(); - useEffect(() => { - if (ref.current !== value) { - ref.current = value; - callback(); - } - }); -} diff --git a/src/hooks/useValueChanged.ts b/src/hooks/useValueChanged.ts new file mode 100644 index 000000000..d06a0f793 --- /dev/null +++ b/src/hooks/useValueChanged.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef } from "react"; + +/** + * A hook useful for executing a side effect after component render (in an effect) if the given + * value changes. Uses strict equals. The primary use of this hook is to prevent a side effect from + * being executed multiple times if the component re-renders after the value has transitioned to its + * action state. + * + * Callback is guaranteed to execute on the first render of the component. This is intentional. A + * consumer of this hook is expected to check the current state of the value and take action based + * on its current state. If the component initially renders with the value in the action state, we'd + * want the resulting side effect to take place immediately, rather than only when the value has + * changed. + * + * Example: + * + * useValueChanged(isLoggedIn, () => { + * if (isLoggedIn) router.push("/home"); + * } + */ +export function useValueChanged(value: T, callback: () => void) { + const ref = useRef(); + useEffect(() => { + if (ref.current !== value) { + ref.current = value; + callback(); + } + }); +} diff --git a/src/pages/auth/login/index.page.tsx b/src/pages/auth/login/index.page.tsx index cd63ea567..9f7fdb55f 100644 --- a/src/pages/auth/login/index.page.tsx +++ b/src/pages/auth/login/index.page.tsx @@ -8,7 +8,7 @@ import { login, loginConnection } from "@/connections/Login"; import { ToastType, useToastContext } from "@/context/toast.provider"; import { useConnection } from "@/hooks/useConnection"; import { useSetInviteToken } from "@/hooks/useInviteToken"; -import { useValueChanged } from "@/hooks/usePrevious"; +import { useValueChanged } from "@/hooks/useValueChanged"; import LoginLayout from "../layout"; import LoginForm from "./components/LoginForm"; From 3cdfca6afb202c2e34ae61fddfeee8a1399d4b25 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 20 Sep 2024 23:56:36 -0700 Subject: [PATCH 047/102] [TM-1272] Remove some props passing of isLoggedIn. (cherry picked from commit bd5127efd025054e98495b83e486b057ab653df4) --- src/components/generic/Layout/MainLayout.tsx | 6 ++---- src/components/generic/Navbar/Navbar.tsx | 9 ++------- src/components/generic/Navbar/NavbarContent.tsx | 6 ++++-- src/pages/_app.tsx | 6 +++--- 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/components/generic/Layout/MainLayout.tsx b/src/components/generic/Layout/MainLayout.tsx index 9bc770e3e..8f289427c 100644 --- a/src/components/generic/Layout/MainLayout.tsx +++ b/src/components/generic/Layout/MainLayout.tsx @@ -2,14 +2,12 @@ import { DetailedHTMLProps, HTMLAttributes, PropsWithChildren } from "react"; import Navbar from "@/components/generic/Navbar/Navbar"; -interface MainLayoutProps extends DetailedHTMLProps, HTMLDivElement> { - isLoggedIn?: boolean; -} +interface MainLayoutProps extends DetailedHTMLProps, HTMLDivElement> {} const MainLayout = (props: PropsWithChildren) => { return (
- +
{props.children}
); diff --git a/src/components/generic/Navbar/Navbar.tsx b/src/components/generic/Navbar/Navbar.tsx index 6c39f2a27..229be519f 100644 --- a/src/components/generic/Navbar/Navbar.tsx +++ b/src/components/generic/Navbar/Navbar.tsx @@ -10,11 +10,7 @@ import { useNavbarContext } from "@/context/navbar.provider"; import Container from "../Layout/Container"; import NavbarContent from "./NavbarContent"; -export interface NavbarProps { - isLoggedIn?: boolean; -} - -const Navbar = (props: NavbarProps): JSX.Element => { +const Navbar = (): JSX.Element => { const { isOpen, setIsOpen, linksDisabled } = useNavbarContext(); const isLg = useMediaQuery("(min-width:1024px)"); @@ -37,7 +33,7 @@ const Navbar = (props: NavbarProps): JSX.Element => { - + @@ -68,7 +64,6 @@ const Navbar = (props: NavbarProps): JSX.Element => { "relative flex flex-col items-center justify-center gap-4 sm:hidden", isOpen && "h-[calc(100vh-70px)]" )} - isLoggedIn={props.isLoggedIn} handleClose={() => setIsOpen?.(false)} /> diff --git a/src/components/generic/Navbar/NavbarContent.tsx b/src/components/generic/Navbar/NavbarContent.tsx index 7372fa387..2ce838f26 100644 --- a/src/components/generic/Navbar/NavbarContent.tsx +++ b/src/components/generic/Navbar/NavbarContent.tsx @@ -7,8 +7,10 @@ import { Else, If, Then, When } from "react-if"; import LanguagesDropdown from "@/components/elements/Inputs/LanguageDropdown/LanguagesDropdown"; import { IconNames } from "@/components/extensive/Icon/Icon"; import List from "@/components/extensive/List/List"; +import { loginConnection } from "@/connections/Login"; import { useNavbarContext } from "@/context/navbar.provider"; import { useLogout } from "@/hooks/logout"; +import { useConnection } from "@/hooks/useConnection"; import { useMyOrg } from "@/hooks/useMyOrg"; import { OptionValue } from "@/types/common"; @@ -16,11 +18,11 @@ import NavbarItem from "./NavbarItem"; import { getNavbarItems } from "./navbarItems"; interface NavbarContentProps extends DetailedHTMLProps, HTMLDivElement> { - isLoggedIn?: boolean; handleClose?: () => void; } -const NavbarContent = ({ isLoggedIn, handleClose, ...rest }: NavbarContentProps) => { +const NavbarContent = ({ handleClose, ...rest }: NavbarContentProps) => { + const [, { isLoggedIn }] = useConnection(loginConnection); const router = useRouter(); const t = useT(); const myOrg = useMyOrg(); diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 8fa0089bd..8e8572f4b 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -73,12 +73,12 @@ const _App = ({ Component, pageProps, props, authToken }: AppProps & { authToken - + - - + + From 81d889583e5ce6380f5fd20fa255dccfc445964a Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 23 Sep 2024 10:29:52 -0700 Subject: [PATCH 048/102] [TM-1272] Remove old AuthContext (cherry picked from commit 945e3140fae549ec37bf13ea01405f68738a920a) --- src/context/auth.provider.test.tsx | 51 --------------------- src/context/auth.provider.tsx | 59 ------------------------- src/generated/apiContext.ts | 6 +-- src/hooks/useUserData.ts | 8 +++- src/pages/_app.tsx | 71 ++++++++++++++---------------- 5 files changed, 42 insertions(+), 153 deletions(-) delete mode 100644 src/context/auth.provider.test.tsx delete mode 100644 src/context/auth.provider.tsx diff --git a/src/context/auth.provider.test.tsx b/src/context/auth.provider.test.tsx deleted file mode 100644 index 9ccade836..000000000 --- a/src/context/auth.provider.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { renderHook } from "@testing-library/react"; - -import AuthProvider, { useAuthContext } from "@/context/auth.provider"; -import * as api from "@/generated/apiComponents"; - -jest.mock("@/generated/apiComponents", () => ({ - __esModule: true, - useGetAuthMe: jest.fn(), - usePostAuthLogin: jest.fn() -})); - -jest.mock("@/generated/apiFetcher", () => ({ - __esModule: true, - apiFetch: jest.fn() -})); - -describe("Test auth.provider context", () => { - const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9"; - const userData = { - uuid: "1234-1234", - name: "3SC" - }; - - beforeEach(() => { - jest.resetAllMocks(); - //@ts-ignore - api.usePostAuthLogin.mockImplementation(() => ({ - mutateAsync: jest.fn(() => Promise.resolve({ data: { token } })), - isLoading: false, - error: null - })); - //@ts-ignore - api.useGetAuthMe.mockReturnValue({ - data: { - data: userData - } - }); - }); - - test("login method update local storage", async () => { - const { result } = renderHook(() => useAuthContext(), { - wrapper: props => {props.children} - }); - - jest.spyOn(window.localStorage.__proto__, "setItem"); - - await result.current.login({ email_address: "example@3sidedcube.com", password: "12345" }); - - expect(localStorage.setItem).toBeCalledWith("access_token", token); - }); -}); diff --git a/src/context/auth.provider.tsx b/src/context/auth.provider.tsx deleted file mode 100644 index 019d483ba..000000000 --- a/src/context/auth.provider.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { createContext, useContext } from "react"; - -import { setAccessToken } from "@/admin/apiProvider/utils/token"; -import { usePostAuthLogin } from "@/generated/apiComponents"; -import { AuthLogIn } from "@/generated/apiSchemas"; - -interface IAuthContext { - login: (body: AuthLogIn, onError?: () => void) => Promise; - loginLoading: boolean; - token?: string; -} - -export const AuthContext = createContext({ - login: async () => {}, - loginLoading: false, - token: "" -}); - -type AuthProviderProps = { children: React.ReactNode; token?: string }; - -const AuthProvider = ({ children, token }: AuthProviderProps) => { - const { mutateAsync: authLogin, isLoading: loginLoading } = usePostAuthLogin(); - - const login = async (body: AuthLogIn, onError?: () => void) => { - return new Promise(r => { - authLogin({ - body - }) - .then(res => { - // @ts-expect-error - const token = res["data"].token; - - setAccessToken(token); - - r({ success: true }); - }) - .catch(() => { - onError?.(); - r({ success: false }); - }); - }); - }; - - return ( - - {children} - - ); -}; - -export const useAuthContext = () => useContext(AuthContext); - -export default AuthProvider; diff --git a/src/generated/apiContext.ts b/src/generated/apiContext.ts index 0a2bac201..b1bbb667a 100644 --- a/src/generated/apiContext.ts +++ b/src/generated/apiContext.ts @@ -1,8 +1,8 @@ import type { QueryKey, UseQueryOptions } from "@tanstack/react-query"; -import { useAuthContext } from "@/context/auth.provider"; - import { QueryOperation } from "./apiComponents"; +import { useConnection } from "@/hooks/useConnection"; +import { loginConnection } from "@/connections/Login"; export type ApiContext = { fetcherOptions: { @@ -41,7 +41,7 @@ export function useApiContext< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey >(_queryOptions?: Omit, "queryKey" | "queryFn">): ApiContext { - const { token } = useAuthContext(); + const [, { token }] = useConnection(loginConnection); return { fetcherOptions: { diff --git a/src/hooks/useUserData.ts b/src/hooks/useUserData.ts index ef1df550c..ec1db36fa 100644 --- a/src/hooks/useUserData.ts +++ b/src/hooks/useUserData.ts @@ -1,13 +1,17 @@ -import { useAuthContext } from "@/context/auth.provider"; +import { loginConnection } from "@/connections/Login"; import { useGetAuthMe } from "@/generated/apiComponents"; import { MeResponse } from "@/generated/apiSchemas"; +import { useConnection } from "@/hooks/useConnection"; /** * To easily access user data * @returns MeResponse + * + * TODO This hooks will be replaced in TM-1312, and the user data will be cached instead of re-fetched + * every 5 minutes for every component that uses this hook. */ export const useUserData = () => { - const { token } = useAuthContext(); + const [, { token }] = useConnection(loginConnection); const { data: authMe } = useGetAuthMe<{ data: MeResponse }>( {}, { diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 8e8572f4b..78e9b3d20 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -14,7 +14,6 @@ import Toast from "@/components/elements/Toast/Toast"; import ModalRoot from "@/components/extensive/Modal/ModalRoot"; import DashboardLayout from "@/components/generic/Layout/DashboardLayout"; import MainLayout from "@/components/generic/Layout/MainLayout"; -import AuthProvider from "@/context/auth.provider"; import { LoadingProvider } from "@/context/loaderAdmin.provider"; import ModalProvider from "@/context/modal.provider"; import NavbarProvider from "@/context/navbar.provider"; @@ -43,16 +42,14 @@ const _App = ({ Component, pageProps, props, authToken }: AppProps & { authToken return ( - - - - - - - - - - + + + + + + + + ); @@ -62,33 +59,31 @@ const _App = ({ Component, pageProps, props, authToken }: AppProps & { authToken - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + From a26aba3b8635063168eb5be7594f74aa4b865a90 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 23 Sep 2024 11:37:18 -0700 Subject: [PATCH 049/102] [TM-1272] Replaced console usage with a new central logger. (cherry picked from commit 079b614028743914c2825053204a77b92208e21e) --- src/admin/apiProvider/authProvider.ts | 19 +++------- src/admin/apiProvider/utils/error.ts | 5 +-- .../Dialogs/FormQuestionPreviewDialog.tsx | 3 +- .../Dialogs/FormSectionPreviewDialog.tsx | 3 +- .../PolygonDrawer/PolygonDrawer.tsx | 5 ++- .../components/AttributeInformation.tsx | 3 +- .../PolygonStatus/StatusDisplay.tsx | 5 ++- .../ResourceTabs/PolygonReviewTab/index.tsx | 7 ++-- .../form/components/CopyFormToOtherEnv.tsx | 5 ++- .../elements/Accordion/Accordion.stories.tsx | 4 +- .../ImageGallery/ImageGalleryItem.tsx | 5 ++- .../Inputs/Dropdown/Dropdown.stories.tsx | 3 +- .../Inputs/FileInput/RHFFileInput.tsx | 3 +- .../elements/Inputs/Select/Select.stories.tsx | 4 +- .../SelectImage/SelectImage.stories.tsx | 4 +- .../TreeSpeciesInput.stories.tsx | 6 ++- .../elements/Map-mapbox/Map.stories.tsx | 5 ++- .../CheckIndividualPolygonControl.tsx | 3 +- .../MapControls/CheckPolygonControl.tsx | 3 +- .../Map-mapbox/MapLayers/GeoJsonLayer.tsx | 3 +- src/components/elements/Map-mapbox/utils.ts | 5 ++- .../MapPolygonPanel/AttributeInformation.tsx | 3 +- .../MapPolygonPanel/ChecklistInformation.tsx | 3 +- .../MapPolygonPanel.stories.tsx | 6 ++- .../MapSidePanel/MapSidePanel.stories.tsx | 14 ++++--- .../EntityMapAndGalleryCard.tsx | 3 +- .../extensive/Modal/FormModal.stories.tsx | 3 +- .../extensive/Modal/Modal.stories.tsx | 6 ++- .../extensive/Modal/ModalImageDetails.tsx | 3 +- .../Modal/ModalWithClose.stories.tsx | 6 ++- .../extensive/Modal/ModalWithLogo.stories.tsx | 6 ++- .../Pagination/PerPageSelector.stories.tsx | 4 +- .../WizardForm/WizardForm.stories.tsx | 9 +++-- src/components/extensive/WizardForm/index.tsx | 9 ++--- src/constants/options/frameworks.ts | 3 +- src/context/mapArea.provider.tsx | 3 +- src/generated/apiFetcher.ts | 9 ++--- src/generated/v3/utils.ts | 3 +- .../useGetCustomFormSteps.stories.tsx | 5 ++- src/hooks/useMessageValidations.ts | 4 +- src/pages/_app.tsx | 3 +- .../components/ApplicationHeader.tsx | 3 +- src/pages/auth/verify/email/[token].page.tsx | 3 +- src/pages/debug/index.page.tsx | 5 ++- src/pages/site/[uuid]/tabs/Overview.tsx | 5 ++- src/utils/geojson.ts | 2 +- src/utils/log.ts | 37 +++++++++++++++++++ src/utils/network.ts | 6 ++- 48 files changed, 173 insertions(+), 96 deletions(-) create mode 100644 src/utils/log.ts diff --git a/src/admin/apiProvider/authProvider.ts b/src/admin/apiProvider/authProvider.ts index a61301080..90922d014 100644 --- a/src/admin/apiProvider/authProvider.ts +++ b/src/admin/apiProvider/authProvider.ts @@ -1,23 +1,14 @@ import { AuthProvider } from "react-admin"; -import { fetchGetAuthLogout, fetchGetAuthMe, fetchPostAuthLogin } from "@/generated/apiComponents"; +import { fetchGetAuthLogout, fetchGetAuthMe } from "@/generated/apiComponents"; +import Log from "@/utils/log"; -import { AdminTokenStorageKey, removeAccessToken, setAccessToken } from "./utils/token"; +import { AdminTokenStorageKey, removeAccessToken } from "./utils/token"; export const authProvider: AuthProvider = { // send username and password to the auth server and get back credentials - login: params => { - return fetchPostAuthLogin({ body: { email_address: params.username, password: params.password } }) - .then(async res => { - //@ts-ignore - const token = res.data.token; - - setAccessToken(token); - }) - .catch(e => { - console.log(e); - throw Error("Wrong username or password"); - }); + login: async params => { + Log.error("Admin app does not support direct login"); }, // when the dataProvider returns an error, check if this is an authentication error diff --git a/src/admin/apiProvider/utils/error.ts b/src/admin/apiProvider/utils/error.ts index 204945203..7a1841e22 100644 --- a/src/admin/apiProvider/utils/error.ts +++ b/src/admin/apiProvider/utils/error.ts @@ -1,10 +1,9 @@ -import * as Sentry from "@sentry/nextjs"; import { HttpError } from "react-admin"; import { ErrorWrapper } from "@/generated/apiFetcher"; +import Log from "@/utils/log"; export const getFormattedErrorForRA = (err: ErrorWrapper) => { - console.log(err); - Sentry.captureException(err); + Log.error("Network error", err?.statusCode, ...(err?.errors ?? [])); return new HttpError(err?.errors?.map?.(e => e.detail).join(", ") || "", err?.statusCode); }; diff --git a/src/admin/components/Dialogs/FormQuestionPreviewDialog.tsx b/src/admin/components/Dialogs/FormQuestionPreviewDialog.tsx index 6d8872f36..2a5ac4076 100644 --- a/src/admin/components/Dialogs/FormQuestionPreviewDialog.tsx +++ b/src/admin/components/Dialogs/FormQuestionPreviewDialog.tsx @@ -18,6 +18,7 @@ import { FieldMapper } from "@/components/extensive/WizardForm/FieldMapper"; import ModalProvider from "@/context/modal.provider"; import { FormQuestionRead, V2GenericList } from "@/generated/apiSchemas"; import { apiFormQuestionToFormField } from "@/helpers/customForms"; +import Log from "@/utils/log"; interface ConfirmationDialogProps extends DialogProps { question?: FormQuestionRead; @@ -54,7 +55,7 @@ export const FormQuestionPreviewDialog = ({ - + Log.debug("Field Mapper onChange")} /> diff --git a/src/admin/components/Dialogs/FormSectionPreviewDialog.tsx b/src/admin/components/Dialogs/FormSectionPreviewDialog.tsx index c2b05f29f..05c3cdefb 100644 --- a/src/admin/components/Dialogs/FormSectionPreviewDialog.tsx +++ b/src/admin/components/Dialogs/FormSectionPreviewDialog.tsx @@ -18,6 +18,7 @@ import { FormStep } from "@/components/extensive/WizardForm/FormStep"; import ModalProvider from "@/context/modal.provider"; import { FormSectionRead, V2GenericList } from "@/generated/apiSchemas"; import { apiFormSectionToFormStep } from "@/helpers/customForms"; +import Log from "@/utils/log"; interface ConfirmationDialogProps extends DialogProps { section?: FormSectionRead; @@ -49,7 +50,7 @@ export const FormSectionPreviewDialog = ({ linkedFieldData, section: _section, . - + Log.debug("FormStep onChange")} /> diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/PolygonDrawer.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/PolygonDrawer.tsx index 34f2d0b79..6a88e0600 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/PolygonDrawer.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/PolygonDrawer.tsx @@ -24,6 +24,7 @@ import { } from "@/generated/apiComponents"; import { ClippedPolygonResponse, SitePolygon, SitePolygonsDataResponse } from "@/generated/apiSchemas"; import { parseValidationData } from "@/helpers/polygonValidation"; +import Log from "@/utils/log"; import CommentarySection from "../CommentarySection/CommentarySection"; import StatusDisplay from "../PolygonStatus/StatusDisplay"; @@ -154,7 +155,7 @@ const PolygonDrawer = ({ hideLoader(); }, onError: error => { - console.error("Error clipping polygons:", error); + Log.error("Error clipping polygons:", error); openNotification("error", t("Error! Could not fix polygons"), t("Please try again later.")); } }); @@ -258,7 +259,7 @@ const PolygonDrawer = ({ showLoader(); clipPolygons({ pathParams: { uuid: polygonSelected } }); } else { - console.error("Polygon UUID is missing"); + Log.error("Polygon UUID is missing"); openNotification("error", t("Error"), t("Cannot fix polygons: Polygon UUID is missing.")); } }; diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/AttributeInformation.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/AttributeInformation.tsx index f3756d86e..9ebc420de 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/AttributeInformation.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonDrawer/components/AttributeInformation.tsx @@ -14,6 +14,7 @@ import { usePostV2TerrafundNewSitePolygonUuidNewVersion } from "@/generated/apiComponents"; import { SitePolygon, SitePolygonsDataResponse } from "@/generated/apiSchemas"; +import Log from "@/utils/log"; const dropdownOptionsRestoration = [ { @@ -211,7 +212,7 @@ const AttributeInformation = ({ } ); } catch (error) { - console.error("Error creating polygon version:", error); + Log.error("Error creating polygon version:", error); } } const response = (await fetchGetV2SitePolygonUuid({ diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonStatus/StatusDisplay.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonStatus/StatusDisplay.tsx index 7e39d0070..667b976af 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonStatus/StatusDisplay.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/PolygonStatus/StatusDisplay.tsx @@ -7,6 +7,7 @@ import ModalConfirm from "@/components/extensive/Modal/ModalConfirm"; import { ModalId } from "@/components/extensive/Modal/ModalConst"; import { useModalContext } from "@/context/modal.provider"; import { useNotificationContext } from "@/context/notification.provider"; +import Log from "@/utils/log"; import { AuditLogEntity, AuditLogEntityEnum } from "../../../AuditLogTab/constants/types"; import { getRequestPathParam } from "../../../AuditLogTab/utils/util"; @@ -271,7 +272,7 @@ const StatusDisplay = ({ "The request encountered an issue, or the comment exceeds 255 characters." ); - console.error(e); + Log.error("The request encountered an issue", e); } finally { onFinallyRequest(); } @@ -307,7 +308,7 @@ const StatusDisplay = ({ "Error!", "The request encountered an issue, or the comment exceeds 255 characters." ); - console.error(e); + Log.error("Request encountered an issue", e); } finally { onFinallyRequest(); } diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx index eaeaa9514..2ccb74d85 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/index.tsx @@ -50,6 +50,7 @@ import { SitePolygonsLoadedDataResponse } from "@/generated/apiSchemas"; import { EntityName, FileType, UploadedFile } from "@/types/common"; +import Log from "@/utils/log"; import ModalIdentified from "../../extensive/Modal/ModalIdentified"; import AddDataButton from "./components/AddDataButton"; @@ -219,7 +220,7 @@ const PolygonReviewTab: FC = props => { linear: false }); } else { - console.error("Bounding box is not in the expected format"); + Log.error("Bounding box is not in the expected format"); } }; @@ -236,7 +237,7 @@ const PolygonReviewTab: FC = props => { } }) .catch(error => { - console.error("Error deleting polygon:", error); + Log.error("Error deleting polygon:", error); }); }; @@ -382,7 +383,7 @@ const PolygonReviewTab: FC = props => { openNotification("success", "Success, Your Polygons were approved!", ""); refetch(); } catch (error) { - console.error(error); + Log.error("Polygon approval error", error); } }} /> diff --git a/src/admin/modules/form/components/CopyFormToOtherEnv.tsx b/src/admin/modules/form/components/CopyFormToOtherEnv.tsx index 18b86d72c..20e76da82 100644 --- a/src/admin/modules/form/components/CopyFormToOtherEnv.tsx +++ b/src/admin/modules/form/components/CopyFormToOtherEnv.tsx @@ -8,6 +8,7 @@ import { appendAdditionalFormQuestionFields } from "@/admin/modules/form/compone import RHFDropdown from "@/components/elements/Inputs/Dropdown/RHFDropdown"; import Input from "@/components/elements/Inputs/Input/Input"; import { fetchGetV2FormsLinkedFieldListing } from "@/generated/apiComponents"; +import Log from "@/utils/log"; const envOptions = [ { @@ -39,7 +40,7 @@ export const CopyFormToOtherEnv = () => { } }); const { register, handleSubmit, formState, getValues } = formHook; - console.log(getValues(), formState.errors); + Log.info(getValues(), formState.errors); const copyToDestinationEnv = async ({ env: baseUrl, title: formTitle, framework_key, ...body }: any) => { const linkedFieldsData: any = await fetchGetV2FormsLinkedFieldListing({}); @@ -50,7 +51,7 @@ export const CopyFormToOtherEnv = () => { }, body: JSON.stringify(body) }); - console.log(loginResp); + Log.debug("Login response", loginResp); if (loginResp.status !== 200) { return notify("wrong username password", { type: "error" }); diff --git a/src/components/elements/Accordion/Accordion.stories.tsx b/src/components/elements/Accordion/Accordion.stories.tsx index 83f3cc134..c5c5b441e 100644 --- a/src/components/elements/Accordion/Accordion.stories.tsx +++ b/src/components/elements/Accordion/Accordion.stories.tsx @@ -1,5 +1,7 @@ import { Meta, StoryObj } from "@storybook/react"; +import Log from "@/utils/log"; + import Accordion from "./Accordion"; const meta: Meta = { @@ -33,7 +35,7 @@ export const WithCTA: Story = { ...Default.args, ctaButtonProps: { text: "Edit", - onClick: console.log + onClick: () => Log.info("CTA clicked") } } }; diff --git a/src/components/elements/ImageGallery/ImageGalleryItem.tsx b/src/components/elements/ImageGallery/ImageGalleryItem.tsx index 80809a089..ae52effca 100644 --- a/src/components/elements/ImageGallery/ImageGalleryItem.tsx +++ b/src/components/elements/ImageGallery/ImageGalleryItem.tsx @@ -14,6 +14,7 @@ import { useNotificationContext } from "@/context/notification.provider"; import { usePatchV2MediaProjectProjectMediaUuid, usePostV2ExportImage } from "@/generated/apiComponents"; import { useGetReadableEntityName } from "@/hooks/entity/useGetReadableEntityName"; import { SingularEntityName } from "@/types/common"; +import Log from "@/utils/log"; import ImageWithChildren from "../ImageWithChildren/ImageWithChildren"; import Menu from "../Menu/Menu"; @@ -97,7 +98,7 @@ const ImageGalleryItem: FC = ({ }); if (!response) { - console.error("No response received from the server."); + Log.error("No response received from the server."); openNotification("error", t("Error!"), t("No response received from the server.")); return; } @@ -116,7 +117,7 @@ const ImageGalleryItem: FC = ({ hideLoader(); openNotification("success", t("Success!"), t("Image downloaded successfully")); } catch (error) { - console.error("Download error:", error); + Log.error("Download error:", error); hideLoader(); } }; diff --git a/src/components/elements/Inputs/Dropdown/Dropdown.stories.tsx b/src/components/elements/Inputs/Dropdown/Dropdown.stories.tsx index 307a6b83f..12577f4ea 100644 --- a/src/components/elements/Inputs/Dropdown/Dropdown.stories.tsx +++ b/src/components/elements/Inputs/Dropdown/Dropdown.stories.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { OptionValue } from "@/types/common"; import { toArray } from "@/utils/array"; +import Log from "@/utils/log"; import Component, { DropdownProps as Props } from "./Dropdown"; @@ -52,7 +53,7 @@ export const SingleSelect: Story = { {...args} value={value} onChange={v => { - console.log(v); + Log.info("onChange", v); setValue(v); }} /> diff --git a/src/components/elements/Inputs/FileInput/RHFFileInput.tsx b/src/components/elements/Inputs/FileInput/RHFFileInput.tsx index 227c21df0..e1d0d3fa4 100644 --- a/src/components/elements/Inputs/FileInput/RHFFileInput.tsx +++ b/src/components/elements/Inputs/FileInput/RHFFileInput.tsx @@ -12,6 +12,7 @@ import { import { UploadedFile } from "@/types/common"; import { toArray } from "@/utils/array"; import { getErrorMessages } from "@/utils/errors"; +import Log from "@/utils/log"; import FileInput, { FileInputProps } from "./FileInput"; import { VARIANT_FILE_INPUT_MODAL_ADD_IMAGES_WITH_MAP } from "./FileInputVariants"; @@ -183,7 +184,7 @@ const RHFFileInput = ({ body.append("lng", location.longitude.toString()); } } catch (e) { - console.log(e); + Log.error("Failed to append geotagging information", e); } upload?.({ diff --git a/src/components/elements/Inputs/Select/Select.stories.tsx b/src/components/elements/Inputs/Select/Select.stories.tsx index f9c650963..913f14bae 100644 --- a/src/components/elements/Inputs/Select/Select.stories.tsx +++ b/src/components/elements/Inputs/Select/Select.stories.tsx @@ -1,5 +1,7 @@ import { Meta, StoryObj } from "@storybook/react"; +import Log from "@/utils/log"; + import Component from "./Select"; const meta: Meta = { @@ -15,7 +17,7 @@ export const Default: Story = { label: "Select label", description: "Select description", placeholder: "placeholder", - onChange: console.log, + onChange: Log.info, options: [ { title: "Option 1", diff --git a/src/components/elements/Inputs/SelectImage/SelectImage.stories.tsx b/src/components/elements/Inputs/SelectImage/SelectImage.stories.tsx index 5cd067b2c..4aa4fc103 100644 --- a/src/components/elements/Inputs/SelectImage/SelectImage.stories.tsx +++ b/src/components/elements/Inputs/SelectImage/SelectImage.stories.tsx @@ -1,5 +1,7 @@ import { Meta, StoryObj } from "@storybook/react"; +import Log from "@/utils/log"; + import Component from "./SelectImage"; const meta: Meta = { @@ -15,7 +17,7 @@ export const Default: Story = { label: "Select Image label", description: "Select Image description", placeholder: "placeholder", - onChange: console.log, + onChange: Log.info, options: [ { title: "Option 1", diff --git a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.stories.tsx b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.stories.tsx index 56d46691d..6e0df7760 100644 --- a/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.stories.tsx +++ b/src/components/elements/Inputs/TreeSpeciesInput/TreeSpeciesInput.stories.tsx @@ -1,6 +1,8 @@ import { Meta, StoryObj } from "@storybook/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import Log from "@/utils/log"; + import Components from "./TreeSpeciesInput"; const meta: Meta = { @@ -32,8 +34,8 @@ export const Default: Story = { amount: 23 } ], - onChange: value => console.log("onChange", value), - clearErrors: () => console.log("clearErrors") + onChange: value => Log.info("onChange", value), + clearErrors: () => Log.info("clearErrors") } }; diff --git a/src/components/elements/Map-mapbox/Map.stories.tsx b/src/components/elements/Map-mapbox/Map.stories.tsx index 16a5e739c..ea8d2a0ce 100644 --- a/src/components/elements/Map-mapbox/Map.stories.tsx +++ b/src/components/elements/Map-mapbox/Map.stories.tsx @@ -6,6 +6,7 @@ import Toast from "@/components/elements/Toast/Toast"; import ModalRoot from "@/components/extensive/Modal/ModalRoot"; import ModalProvider from "@/context/modal.provider"; import ToastProvider from "@/context/toast.provider"; +import Log from "@/utils/log"; import Component from "./Map"; import sample from "./sample.json"; @@ -34,8 +35,8 @@ export const Default: Story = { ) ], args: { - onGeojsonChange: console.log, - onError: errors => console.log(JSON.stringify(errors)) + onGeojsonChange: Log.info, + onError: errors => Log.info(JSON.stringify(errors)) } }; diff --git a/src/components/elements/Map-mapbox/MapControls/CheckIndividualPolygonControl.tsx b/src/components/elements/Map-mapbox/MapControls/CheckIndividualPolygonControl.tsx index ce10f1775..b60108a7c 100644 --- a/src/components/elements/Map-mapbox/MapControls/CheckIndividualPolygonControl.tsx +++ b/src/components/elements/Map-mapbox/MapControls/CheckIndividualPolygonControl.tsx @@ -11,6 +11,7 @@ import { usePostV2TerrafundValidationPolygon } from "@/generated/apiComponents"; import { ClippedPolygonResponse, SitePolygonsDataResponse } from "@/generated/apiSchemas"; +import Log from "@/utils/log"; import Button from "../../Button/Button"; @@ -77,7 +78,7 @@ const CheckIndividualPolygonControl = ({ viewRequestSuport }: { viewRequestSupor hideLoader(); }, onError: error => { - console.error("Error clipping polygons:", error); + Log.error("Error clipping polygons:", error); openNotification("error", t("Error! Could not fix polygons"), t("Please try again later.")); } }); diff --git a/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx b/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx index fcfc4ea3a..9c85f574c 100644 --- a/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx +++ b/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx @@ -22,6 +22,7 @@ import { usePostV2TerrafundValidationSitePolygons } from "@/generated/apiComponents"; import { ClippedPolygonResponse, SitePolygon } from "@/generated/apiSchemas"; +import Log from "@/utils/log"; import Button from "../../Button/Button"; import Text from "../../Text/Text"; @@ -120,7 +121,7 @@ const CheckPolygonControl = (props: CheckSitePolygonProps) => { closeModal(ModalId.FIX_POLYGONS); }, onError: error => { - console.error("Error clipping polygons:", error); + Log.error("Error clipping polygons:", error); displayNotification(t("An error occurred while fixing polygons. Please try again."), "error", t("Error")); } }); diff --git a/src/components/elements/Map-mapbox/MapLayers/GeoJsonLayer.tsx b/src/components/elements/Map-mapbox/MapLayers/GeoJsonLayer.tsx index c6f5872d9..8dab3f574 100644 --- a/src/components/elements/Map-mapbox/MapLayers/GeoJsonLayer.tsx +++ b/src/components/elements/Map-mapbox/MapLayers/GeoJsonLayer.tsx @@ -3,6 +3,7 @@ import { LngLatBoundsLike } from "mapbox-gl"; import { useEffect } from "react"; import { useMapContext } from "@/context/map.provider"; +import Log from "@/utils/log"; interface GeoJSONLayerProps { geojson: any; @@ -18,7 +19,7 @@ export const GeoJSONLayer = ({ geojson }: GeoJSONLayerProps) => { draw?.set(geojson); map.fitBounds(bbox(geojson) as LngLatBoundsLike, { padding: 50, animate: false }); } catch (e) { - console.log("invalid geoJSON", e); + Log.error("invalid geoJSON", e); } }, [draw, geojson, map]); diff --git a/src/components/elements/Map-mapbox/utils.ts b/src/components/elements/Map-mapbox/utils.ts index 7cb40b89e..07e76e564 100644 --- a/src/components/elements/Map-mapbox/utils.ts +++ b/src/components/elements/Map-mapbox/utils.ts @@ -17,6 +17,7 @@ import { useGetV2TerrafundPolygonBboxUuid } from "@/generated/apiComponents"; import { DashboardGetProjectsData, SitePolygon, SitePolygonsDataResponse } from "@/generated/apiSchemas"; +import Log from "@/utils/log"; import { MediaPopup } from "./components/MediaPopup"; import { BBox, Feature, FeatureCollection, GeoJsonProperties, Geometry } from "./GeoJSON"; @@ -109,7 +110,7 @@ const showPolygons = ( styles.forEach((style: LayerWithStyle, index: number) => { const layerName = `${name}-${index}`; if (!map.getLayer(layerName)) { - console.warn(`Layer ${layerName} does not exist.`); + Log.warn(`Layer ${layerName} does not exist.`); return; } const polygonStatus = style?.metadata?.polygonStatus; @@ -153,7 +154,7 @@ const handleLayerClick = ( const feature = features?.[0]; if (!feature) { - console.warn("No feature found in click event"); + Log.warn("No feature found in click event"); return; } diff --git a/src/components/elements/MapPolygonPanel/AttributeInformation.tsx b/src/components/elements/MapPolygonPanel/AttributeInformation.tsx index b914412b7..05f893ce2 100644 --- a/src/components/elements/MapPolygonPanel/AttributeInformation.tsx +++ b/src/components/elements/MapPolygonPanel/AttributeInformation.tsx @@ -8,6 +8,7 @@ import { useMapAreaContext } from "@/context/mapArea.provider"; import { useNotificationContext } from "@/context/notification.provider"; import { useGetV2TerrafundPolygonUuid, usePutV2TerrafundSitePolygonUuid } from "@/generated/apiComponents"; import { SitePolygon } from "@/generated/apiSchemas"; +import Log from "@/utils/log"; import Text from "../Text/Text"; import { useTranslatedOptions } from "./hooks/useTranslatedOptions"; @@ -175,7 +176,7 @@ const AttributeInformation = ({ handleClose }: { handleClose: () => void }) => { } ); } catch (error) { - console.error("Error updating polygon data:", error); + Log.error("Error updating polygon data:", error); } } }; diff --git a/src/components/elements/MapPolygonPanel/ChecklistInformation.tsx b/src/components/elements/MapPolygonPanel/ChecklistInformation.tsx index 60d6277b1..b04ca404d 100644 --- a/src/components/elements/MapPolygonPanel/ChecklistInformation.tsx +++ b/src/components/elements/MapPolygonPanel/ChecklistInformation.tsx @@ -8,6 +8,7 @@ import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import { V2TerrafundCriteriaData } from "@/generated/apiSchemas"; import { isCompletedDataOrEstimatedArea } from "@/helpers/polygonValidation"; import { useMessageValidators } from "@/hooks/useMessageValidations"; +import Log from "@/utils/log"; import Text from "../Text/Text"; @@ -24,7 +25,7 @@ export const validationLabels: any = { }; function useRenderCounter() { const ref = useRef(0); - console.log(`Render count: ${++ref.current}`); + Log.debug(`Render count: ${++ref.current}`); } const ChecklistInformation = ({ criteriaData }: { criteriaData: V2TerrafundCriteriaData }) => { useRenderCounter(); diff --git a/src/components/elements/MapPolygonPanel/MapPolygonPanel.stories.tsx b/src/components/elements/MapPolygonPanel/MapPolygonPanel.stories.tsx index aaff2349f..517286631 100644 --- a/src/components/elements/MapPolygonPanel/MapPolygonPanel.stories.tsx +++ b/src/components/elements/MapPolygonPanel/MapPolygonPanel.stories.tsx @@ -1,6 +1,8 @@ import { Meta, StoryObj } from "@storybook/react"; import { useState } from "react"; +import Log from "@/utils/log"; + import Component from "./MapPolygonPanel"; const meta: Meta = { @@ -95,7 +97,7 @@ export const Default: Story = { }, args: { title: "Project Sites", - onSelectItem: console.log + onSelectItem: Log.info } }; @@ -113,6 +115,6 @@ export const OpenPolygonCheck: Story = { }, args: { title: "Project Sites", - onSelectItem: console.log + onSelectItem: Log.info } }; diff --git a/src/components/elements/MapSidePanel/MapSidePanel.stories.tsx b/src/components/elements/MapSidePanel/MapSidePanel.stories.tsx index 843f91075..1cac4cadf 100644 --- a/src/components/elements/MapSidePanel/MapSidePanel.stories.tsx +++ b/src/components/elements/MapSidePanel/MapSidePanel.stories.tsx @@ -1,6 +1,8 @@ import { Meta, StoryObj } from "@storybook/react"; import { useState } from "react"; +import Log from "@/utils/log"; + import Component from "./MapSidePanel"; const meta: Meta = { @@ -35,7 +37,7 @@ const items = [ title: "Puerto Princesa Subterranean River National Park Forest Corridor", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: console.log, + setClickedButton: Log.info, onCheckboxChange: () => {}, refContainer: null, type: "sites", @@ -46,7 +48,7 @@ const items = [ title: "A medium sized project site to see how it looks with 2 lines", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: console.log, + setClickedButton: Log.info, onCheckboxChange: () => {}, refContainer: null, type: "sites", @@ -57,7 +59,7 @@ const items = [ title: "A shorter project site", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: console.log, + setClickedButton: Log.info, onCheckboxChange: () => {}, refContainer: null, type: "sites", @@ -69,7 +71,7 @@ const items = [ "Very long name A medium sized project site to see how it looks with 2 lines A medium sized project site to see how it looks with 2 lines A medium sized project site to see how it looks with 2 lines", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: console.log, + setClickedButton: Log.info, onCheckboxChange: () => {}, refContainer: null, type: "sites", @@ -80,7 +82,7 @@ const items = [ title: "A shorter project site", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: console.log, + setClickedButton: Log.info, onCheckboxChange: () => {}, refContainer: null, type: "sites", @@ -91,7 +93,7 @@ const items = [ title: "A shorter project site", subtitle: "Created 03/12/21", status: "submitted", - setClickedButton: console.log, + setClickedButton: Log.info, onCheckboxChange: () => {}, refContainer: null, type: "sites", diff --git a/src/components/extensive/EntityMapAndGalleryCard/EntityMapAndGalleryCard.tsx b/src/components/extensive/EntityMapAndGalleryCard/EntityMapAndGalleryCard.tsx index d8bd440b5..2d9377f5f 100644 --- a/src/components/extensive/EntityMapAndGalleryCard/EntityMapAndGalleryCard.tsx +++ b/src/components/extensive/EntityMapAndGalleryCard/EntityMapAndGalleryCard.tsx @@ -26,6 +26,7 @@ import { import { getCurrentPathEntity } from "@/helpers/entity"; import { useGetImagesGeoJSON } from "@/hooks/useImageGeoJSON"; import { EntityName, FileType } from "@/types/common"; +import Log from "@/utils/log"; import ModalAddImages from "../Modal/ModalAddImages"; import { ModalId } from "../Modal/ModalConst"; @@ -166,7 +167,7 @@ const EntityMapAndGalleryCard = ({ collection="media" entityData={entityData} setErrorMessage={message => { - console.error(message); + Log.error(message); }} /> ); diff --git a/src/components/extensive/Modal/FormModal.stories.tsx b/src/components/extensive/Modal/FormModal.stories.tsx index a3379004c..b3538b6fe 100644 --- a/src/components/extensive/Modal/FormModal.stories.tsx +++ b/src/components/extensive/Modal/FormModal.stories.tsx @@ -2,6 +2,7 @@ import { Meta, StoryObj } from "@storybook/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { getDisturbanceTableFields } from "@/components/elements/Inputs/DataTable/RHFDisturbanceTable"; +import Log from "@/utils/log"; import Component, { FormModalProps as Props } from "./FormModal"; @@ -25,6 +26,6 @@ export const Default: Story = { args: { title: "Add new disturbance", fields: getDisturbanceTableFields({ hasIntensity: true, hasExtent: true }), - onSubmit: console.log + onSubmit: Log.info } }; diff --git a/src/components/extensive/Modal/Modal.stories.tsx b/src/components/extensive/Modal/Modal.stories.tsx index 70bc34abd..e6056db8d 100644 --- a/src/components/extensive/Modal/Modal.stories.tsx +++ b/src/components/extensive/Modal/Modal.stories.tsx @@ -1,5 +1,7 @@ import { Meta, StoryObj } from "@storybook/react"; +import Log from "@/utils/log"; + import { IconNames } from "../Icon/Icon"; import Component, { ModalProps as Props } from "./Modal"; @@ -27,11 +29,11 @@ export const Default: Story = { }, primaryButtonProps: { children: "Close and continue later", - onClick: console.log + onClick: () => Log.info("close clicked") }, secondaryButtonProps: { children: "Cancel", - onClick: console.log + onClick: () => Log.info("secondary clicked") } } }; diff --git a/src/components/extensive/Modal/ModalImageDetails.tsx b/src/components/extensive/Modal/ModalImageDetails.tsx index 346c4fea4..9f9f12065 100644 --- a/src/components/extensive/Modal/ModalImageDetails.tsx +++ b/src/components/extensive/Modal/ModalImageDetails.tsx @@ -14,6 +14,7 @@ import Modal from "@/components/extensive/Modal/Modal"; import { useModalContext } from "@/context/modal.provider"; import { useNotificationContext } from "@/context/notification.provider"; import { usePatchV2MediaProjectProjectMediaUuid, usePatchV2MediaUuid } from "@/generated/apiComponents"; +import Log from "@/utils/log"; import Icon, { IconNames } from "../Icon/Icon"; import PageBreadcrumbs from "../PageElements/Breadcrumbs/PageBreadcrumbs"; @@ -136,7 +137,7 @@ const ModalImageDetails: FC = ({ onClose?.(); } catch (error) { openNotification("error", t("Error"), t("Failed to update image details")); - console.error("Failed to update image details:", error); + Log.error("Failed to update image details:", error); } }; diff --git a/src/components/extensive/Modal/ModalWithClose.stories.tsx b/src/components/extensive/Modal/ModalWithClose.stories.tsx index d8fed45e4..f93f542c4 100644 --- a/src/components/extensive/Modal/ModalWithClose.stories.tsx +++ b/src/components/extensive/Modal/ModalWithClose.stories.tsx @@ -1,5 +1,7 @@ import { Meta, StoryObj } from "@storybook/react"; +import Log from "@/utils/log"; + import { IconNames } from "../Icon/Icon"; import { ModalProps as Props } from "./Modal"; import Component from "./ModalWithClose"; @@ -28,11 +30,11 @@ export const Default: Story = { }, primaryButtonProps: { children: "Close and continue later", - onClick: console.log + onClick: () => Log.info("close clicked") }, secondaryButtonProps: { children: "Cancel", - onClick: console.log + onClick: () => Log.info("secondary clicked") } } }; diff --git a/src/components/extensive/Modal/ModalWithLogo.stories.tsx b/src/components/extensive/Modal/ModalWithLogo.stories.tsx index a55823498..127845067 100644 --- a/src/components/extensive/Modal/ModalWithLogo.stories.tsx +++ b/src/components/extensive/Modal/ModalWithLogo.stories.tsx @@ -1,6 +1,8 @@ import { Meta, StoryObj } from "@storybook/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import Log from "@/utils/log"; + import { IconNames } from "../Icon/Icon"; import Component, { ModalWithLogoProps as Props } from "./ModalWithLogo"; @@ -32,11 +34,11 @@ export const Default: Story = { }, primaryButtonProps: { children: "Close and continue later", - onClick: console.log + onClick: () => Log.info("close clicked") }, secondaryButtonProps: { children: "Cancel", - onClick: console.log + onClick: () => Log.info("secondary clicked") } } }; diff --git a/src/components/extensive/Pagination/PerPageSelector.stories.tsx b/src/components/extensive/Pagination/PerPageSelector.stories.tsx index 1c69a6f0a..e13a69391 100644 --- a/src/components/extensive/Pagination/PerPageSelector.stories.tsx +++ b/src/components/extensive/Pagination/PerPageSelector.stories.tsx @@ -1,5 +1,7 @@ import { Meta, StoryObj } from "@storybook/react"; +import Log from "@/utils/log"; + import Component from "./PerPageSelector"; const meta: Meta = { @@ -18,5 +20,5 @@ export const Default: Story = {
) ], - args: { options: [5, 10, 15, 20, 50], onChange: console.log, defaultValue: 5 } + args: { options: [5, 10, 15, 20, 50], onChange: Log.info, defaultValue: 5 } }; diff --git a/src/components/extensive/WizardForm/WizardForm.stories.tsx b/src/components/extensive/WizardForm/WizardForm.stories.tsx index ac87c9ea5..d351a3f2b 100644 --- a/src/components/extensive/WizardForm/WizardForm.stories.tsx +++ b/src/components/extensive/WizardForm/WizardForm.stories.tsx @@ -10,6 +10,7 @@ import { } from "@/components/elements/Inputs/DataTable/RHFFundingTypeDataTable"; import { getCountriesOptions } from "@/constants/options/countries"; import { FileType } from "@/types/common"; +import Log from "@/utils/log"; import Component, { WizardFormProps as Props } from "."; import { FieldType, FormStepSchema } from "./types"; @@ -308,8 +309,8 @@ export const CreateForm: Story = { ), args: { steps: getSteps(false), - onStepChange: console.log, - onChange: console.log, + onStepChange: Log.info, + onChange: Log.info, nextButtonText: "Save and Continue", submitButtonText: "Submit", hideBackButton: false, @@ -326,8 +327,8 @@ export const EditForm = { ...CreateForm, args: { steps: getSteps(true), - onStepChange: console.log, - onChange: console.log, + onStepChange: Log.info, + onChange: Log.info, nextButtonText: "Save", submitButtonText: "Save", hideBackButton: true, diff --git a/src/components/extensive/WizardForm/index.tsx b/src/components/extensive/WizardForm/index.tsx index 887c5a9f3..4cea6fcb1 100644 --- a/src/components/extensive/WizardForm/index.tsx +++ b/src/components/extensive/WizardForm/index.tsx @@ -12,6 +12,7 @@ import { FormStepSchema } from "@/components/extensive/WizardForm/types"; import { useModalContext } from "@/context/modal.provider"; import { ErrorWrapper } from "@/generated/apiFetcher"; import { useDebounce } from "@/hooks/useDebounce"; +import Log from "@/utils/log"; import { ModalId } from "../Modal/ModalConst"; import { FormFooter } from "./FormFooter"; @@ -88,11 +89,9 @@ function WizardForm(props: WizardFormProps) { const formHasError = Object.values(formHook.formState.errors || {}).filter(item => !!item).length > 0; - if (process.env.NODE_ENV === "development") { - console.debug("Form Steps", props.steps); - console.debug("Form Values", formHook.watch()); - console.debug("Form Errors", formHook.formState.errors); - } + Log.debug("Form Steps", props.steps); + Log.debug("Form Values", formHook.watch()); + Log.debug("Form Errors", formHook.formState.errors); const onChange = useDebounce(() => !formHasError && props.onChange?.(formHook.getValues())); diff --git a/src/constants/options/frameworks.ts b/src/constants/options/frameworks.ts index 31f0c4812..271a9c810 100644 --- a/src/constants/options/frameworks.ts +++ b/src/constants/options/frameworks.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { GetListParams } from "react-admin"; import { reportingFrameworkDataProvider } from "@/admin/apiProvider/dataProviders/reportingFrameworkDataProvider"; +import Log from "@/utils/log"; async function getFrameworkChoices() { const params: GetListParams = { @@ -29,7 +30,7 @@ export function useFrameworkChoices() { try { setFrameworkChoices(await getFrameworkChoices()); } catch (error) { - console.error("Error fetching framework choices", error); + Log.error("Error fetching framework choices", error); } }; diff --git a/src/context/mapArea.provider.tsx b/src/context/mapArea.provider.tsx index d39b1e31b..00e0da048 100644 --- a/src/context/mapArea.provider.tsx +++ b/src/context/mapArea.provider.tsx @@ -2,6 +2,7 @@ import React, { createContext, ReactNode, useContext, useState } from "react"; import { fetchGetV2DashboardViewProjectUuid } from "@/generated/apiComponents"; import { SitePolygon } from "@/generated/apiSchemas"; +import Log from "@/utils/log"; type MapAreaType = { isMonitoring: boolean; @@ -134,7 +135,7 @@ export const MapAreaProvider: React.FC<{ children: ReactNode }> = ({ children }) }); setIsMonitoring(isMonitoringPartner?.allowed ?? false); } catch (error) { - console.error("Failed to check if monitoring partner:", error); + Log.error("Failed to check if monitoring partner:", error); setIsMonitoring(false); } }; diff --git a/src/generated/apiFetcher.ts b/src/generated/apiFetcher.ts index bcc311b57..ddd7c77b0 100644 --- a/src/generated/apiFetcher.ts +++ b/src/generated/apiFetcher.ts @@ -1,6 +1,7 @@ import { AdminTokenStorageKey } from "../admin/apiProvider/utils/token"; import { ApiContext } from "./apiContext"; import FormData from "form-data"; +import Log from "@/utils/log"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL + "/api"; @@ -86,9 +87,7 @@ export async function apiFetch< ...(await response.json()) }; } catch (e) { - if (process.env.NODE_ENV === "development") { - console.log("apiFetch", e); - } + Log.error("v1/2 API Fetch error", e); error = { statusCode: -1 }; @@ -104,9 +103,7 @@ export async function apiFetch< return (await response.blob()) as unknown as TData; } } catch (e) { - if (process.env.NODE_ENV === "development") { - console.log("apiFetch", e); - } + Log.error("v1/2 API Fetch error", e); error = { statusCode: response?.status || -1, //@ts-ignore diff --git a/src/generated/v3/utils.ts b/src/generated/v3/utils.ts index 87b3f8ea8..25905b80f 100644 --- a/src/generated/v3/utils.ts +++ b/src/generated/v3/utils.ts @@ -1,4 +1,5 @@ import ApiSlice, { ApiDataStore, isErrorState, isInProgress, Method, PendingErrorState } from "@/store/apiSlice"; +import Log from "@/utils/log"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; @@ -76,7 +77,7 @@ export async function dispatchRequest(url: string, requestInit: R ApiSlice.fetchSucceeded({ ...actionPayload, response: responsePayload }); } } catch (e) { - console.error("Unexpected API fetch failure", e); + Log.error("Unexpected API fetch failure", e); const message = e instanceof Error ? `Network error (${e.message})` : "Network error"; ApiSlice.fetchFailed({ ...actionPayload, error: { statusCode: -1, message } }); } diff --git a/src/hooks/useGetCustomFormSteps/useGetCustomFormSteps.stories.tsx b/src/hooks/useGetCustomFormSteps/useGetCustomFormSteps.stories.tsx index b7ac916d8..05d4dec17 100644 --- a/src/hooks/useGetCustomFormSteps/useGetCustomFormSteps.stories.tsx +++ b/src/hooks/useGetCustomFormSteps/useGetCustomFormSteps.stories.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import WizardForm, { WizardFormProps } from "@/components/extensive/WizardForm"; import { FormRead } from "@/generated/apiSchemas"; import { getCustomFormSteps } from "@/helpers/customForms"; +import Log from "@/utils/log"; import formSchema from "./formSchema.json"; @@ -27,8 +28,8 @@ export const WithGetFormStepHook: Story = { ), args: { steps: getCustomFormSteps(formSchema as FormRead, (t: any) => t), - onStepChange: console.log, - onChange: console.log, + onStepChange: Log.info, + onChange: Log.info, nextButtonText: "Save and Continue", submitButtonText: "Submit", hideBackButton: false, diff --git a/src/hooks/useMessageValidations.ts b/src/hooks/useMessageValidations.ts index 1bacd8602..f88dc552a 100644 --- a/src/hooks/useMessageValidations.ts +++ b/src/hooks/useMessageValidations.ts @@ -1,6 +1,8 @@ import { useT } from "@transifex/react"; import { useMemo } from "react"; +import Log from "@/utils/log"; + interface IntersectionInfo { intersectSmaller: boolean; percentage: number; @@ -51,7 +53,7 @@ export const useMessageValidators = () => { }); }); } catch (error) { - console.error(error); + Log.error("Failed to get intersection messages", error); return [t("Error parsing extra info.")]; } }, diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 78e9b3d20..125b70206 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -23,6 +23,7 @@ import RouteHistoryProvider from "@/context/routeHistory.provider"; import ToastProvider from "@/context/toast.provider"; import { getServerSideTranslations, setClientSideTranslations } from "@/i18n"; import StoreProvider from "@/store/StoreProvider"; +import Log from "@/utils/log"; import setupYup from "@/yup.locale"; const CookieBanner = dynamic(() => import("@/components/extensive/CookieBanner/CookieBanner"), { @@ -99,7 +100,7 @@ _App.getInitialProps = async (context: AppContext) => { try { translationsData = await getServerSideTranslations(context.ctx); } catch (err) { - console.log("Failed to get Serverside Transifex", err); + Log.warn("Failed to get Serverside Transifex", err); } return { ...ctx, props: { ...translationsData }, authToken: cookies.accessToken }; }; diff --git a/src/pages/applications/components/ApplicationHeader.tsx b/src/pages/applications/components/ApplicationHeader.tsx index 59d5e2b66..b9c92fa39 100644 --- a/src/pages/applications/components/ApplicationHeader.tsx +++ b/src/pages/applications/components/ApplicationHeader.tsx @@ -4,6 +4,7 @@ import { When } from "react-if"; import Button from "@/components/elements/Button/Button"; import PageHeader from "@/components/extensive/PageElements/Header/PageHeader"; import { fetchGetV2ApplicationsUUIDExport } from "@/generated/apiComponents"; +import Log from "@/utils/log"; import { downloadFileBlob } from "@/utils/network"; interface ApplicationHeaderProps { @@ -25,7 +26,7 @@ const ApplicationHeader = ({ name, status, uuid }: ApplicationHeaderProps) => { if (!res) return; return downloadFileBlob(res, "Application.csv"); } catch (err) { - console.log(err); + Log.error("Failed to fetch applications exports", err); } }; diff --git a/src/pages/auth/verify/email/[token].page.tsx b/src/pages/auth/verify/email/[token].page.tsx index 0ff27a388..27ecaf781 100644 --- a/src/pages/auth/verify/email/[token].page.tsx +++ b/src/pages/auth/verify/email/[token].page.tsx @@ -9,6 +9,7 @@ import { IconNames } from "@/components/extensive/Icon/Icon"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; import { fetchPatchV2AuthVerify } from "@/generated/apiComponents"; +import Log from "@/utils/log"; const VerifyEmail: NextPage> = () => { const t = useT(); @@ -40,7 +41,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { try { await fetchPatchV2AuthVerify({ body: { token } }); } catch (e) { - console.log(e); + Log.error("Failed to verify auth", e); options = { redirect: { permanent: false, diff --git a/src/pages/debug/index.page.tsx b/src/pages/debug/index.page.tsx index ce3cc342d..91a8b728d 100644 --- a/src/pages/debug/index.page.tsx +++ b/src/pages/debug/index.page.tsx @@ -8,6 +8,7 @@ import PageBody from "@/components/extensive/PageElements/Body/PageBody"; import PageCard from "@/components/extensive/PageElements/Card/PageCard"; import PageHeader from "@/components/extensive/PageElements/Header/PageHeader"; import PageSection from "@/components/extensive/PageElements/Section/PageSection"; +import Log from "@/utils/log"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL + "/api"; @@ -27,7 +28,7 @@ const DebugPage = () => { }; } catch (e) { if (process.env.NODE_ENV === "development") { - console.log("apiFetch", e); + Log.error("apiFetch", e); } error = { statusCode: -1 @@ -38,7 +39,7 @@ const DebugPage = () => { } } catch (e) { if (process.env.NODE_ENV === "development") { - console.log("apiFetch", e); + Log.error("apiFetch", e); } error = { statusCode: response?.status || -1, diff --git a/src/pages/site/[uuid]/tabs/Overview.tsx b/src/pages/site/[uuid]/tabs/Overview.tsx index 5e3ad683a..e1638ead6 100644 --- a/src/pages/site/[uuid]/tabs/Overview.tsx +++ b/src/pages/site/[uuid]/tabs/Overview.tsx @@ -40,6 +40,7 @@ import { SitePolygonsDataResponse, SitePolygonsLoadedDataResponse } from "@/gene import { getEntityDetailPageLink } from "@/helpers/entity"; import { statusActionsMap } from "@/hooks/AuditStatus/useAuditLogActions"; import { FileType, UploadedFile } from "@/types/common"; +import Log from "@/utils/log"; import SiteArea from "../components/SiteArea"; @@ -164,7 +165,7 @@ const SiteOverviewTab = ({ site, refetch: refetchEntity }: SiteOverviewTabProps) setSubmitPolygonLoaded(false); } catch (error) { if (error && typeof error === "object" && "message" in error) { - let errorMessage = error.message as string; + let errorMessage = (error as { message: string }).message; const parsedMessage = JSON.parse(errorMessage); if (parsedMessage && typeof parsedMessage === "object" && "message" in parsedMessage) { errorMessage = parsedMessage.message; @@ -348,7 +349,7 @@ const SiteOverviewTab = ({ site, refetch: refetchEntity }: SiteOverviewTabProps) setShouldRefetchPolygonData(true); openNotification("success", t("Success! Your polygons were submitted.")); } catch (error) { - console.log(error); + Log.error("Failed to fetch polygon statuses", error); } }} /> diff --git a/src/utils/geojson.ts b/src/utils/geojson.ts index d4060f25d..8cf2035d8 100644 --- a/src/utils/geojson.ts +++ b/src/utils/geojson.ts @@ -16,7 +16,7 @@ import normalize from "@mapbox/geojson-normalize"; * { type: 'Feature', geometry: { type: 'Point', coordinates: [0, 1] }, properties: {} } * ]); * - * console.log(JSON.stringify(mergedGeoJSON)); + * Log.debug(JSON.stringify(mergedGeoJSON)); */ export const merge = (inputs: any) => { var output: any = { diff --git a/src/utils/log.ts b/src/utils/log.ts new file mode 100644 index 000000000..8375c89ef --- /dev/null +++ b/src/utils/log.ts @@ -0,0 +1,37 @@ +import { captureException, captureMessage, SeverityLevel, withScope } from "@sentry/nextjs"; + +const IS_PROD = process.env.NODE_ENV === "production"; + +const sentryLog = (level: SeverityLevel, message: any, optionalParams: any[]) => { + const error = optionalParams.find(param => param instanceof Error); + + withScope(scope => { + if (error == null) { + scope.setExtras({ optionalParams }); + captureMessage(message, level); + } else { + scope.setExtras({ message, optionalParams }); + captureException(error); + } + }); +}; + +export default class Log { + static debug(message: any, ...optionalParams: any[]) { + if (!IS_PROD) console.debug(message, ...optionalParams); + } + + static info(message: any, ...optionalParams: any[]) { + if (!IS_PROD) console.info(message, ...optionalParams); + } + + static warn(message: any, ...optionalParams: any[]) { + console.warn(message, ...optionalParams); + sentryLog("warning", message, optionalParams); + } + + static error(message: any, ...optionalParams: any[]) { + console.error(message, ...optionalParams); + sentryLog("error", message, optionalParams); + } +} diff --git a/src/utils/network.ts b/src/utils/network.ts index e93a9c366..d200ab381 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -2,6 +2,8 @@ import { QueryClient } from "@tanstack/react-query"; import { GetServerSidePropsContext } from "next"; import nookies from "nookies"; +import Log from "@/utils/log"; + /** * Prefetch queries in ServerSideProps * @param queryClient Tanstack QueryClient @@ -32,7 +34,7 @@ export const downloadFile = async (fileUrl: string) => { const blob = await res.blob(); downloadFileBlob(blob, fileName); } catch (err) { - console.log(err); + Log.error("Failed to download file", fileUrl, err); } }; @@ -53,6 +55,6 @@ export const downloadFileBlob = async (blob: Blob, fileName: string) => { // Clean up and remove the link link?.parentNode?.removeChild(link); } catch (err) { - console.log(err); + Log.error("Failed to download blob", fileName, err); } }; From 4895d496f57ec8a894c78472910a7f81dbf5f6a7 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 23 Sep 2024 13:37:45 -0700 Subject: [PATCH 050/102] [TM-1272] Unit tests for useConnection. (cherry picked from commit a8c6d7b3780cf709583f3c47cc8185e12f472092) --- src/connections/Login.ts | 2 + src/hooks/useConnection.test.ts | 79 +++++++++++++++++++++++++++++++++ src/store/store.ts | 2 +- 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useConnection.test.ts diff --git a/src/connections/Login.ts b/src/connections/Login.ts index 16ddeface..fbaaa1fc1 100644 --- a/src/connections/Login.ts +++ b/src/connections/Login.ts @@ -16,6 +16,8 @@ type LoginConnection = { export const login = (emailAddress: string, password: string) => authLogin({ body: { emailAddress, password } }); export const logout = () => { removeAccessToken(); + // When we log out, remove all cached API resources so that when we log in again, these resources + // are freshly fetched from the BE. ApiSlice.clearApiCache(); }; diff --git a/src/hooks/useConnection.test.ts b/src/hooks/useConnection.test.ts new file mode 100644 index 000000000..f60ea6c32 --- /dev/null +++ b/src/hooks/useConnection.test.ts @@ -0,0 +1,79 @@ +import { renderHook } from "@testing-library/react"; +import { act } from "react-dom/test-utils"; +import { createSelector } from "reselect"; + +import { LoginResponse } from "@/generated/v3/userService/userServiceSchemas"; +import { useConnection } from "@/hooks/useConnection"; +import ApiSlice, { ApiDataStore, JsonApiResource } from "@/store/apiSlice"; +import { makeStore } from "@/store/store"; +import { Connection } from "@/types/connection"; + +describe("Test useConnection hook", () => { + beforeEach(() => { + ApiSlice.store = makeStore(); + }); + + test("isLoaded", () => { + const load = jest.fn(); + let connectionLoaded = false; + const connection = { + selector: () => ({ connectionLoaded }), + load, + isLoaded: ({ connectionLoaded }) => connectionLoaded + } as Connection<{ connectionLoaded: boolean }>; + let rendered = renderHook(() => useConnection(connection)); + + expect(rendered.result.current[0]).toBe(false); + expect(load).toHaveBeenCalled(); + + load.mockReset(); + connectionLoaded = true; + rendered = renderHook(() => useConnection(connection)); + expect(rendered.result.current[0]).toBe(true); + expect(load).toHaveBeenCalled(); + }); + + test("selector efficiency", () => { + const selector = jest.fn(({ logins }: ApiDataStore) => logins); + const payloadCreator = jest.fn(logins => { + const values = Object.values(logins); + return { login: values.length < 1 ? null : values[0] }; + }); + const connection = { + selector: createSelector([selector], payloadCreator) + } as Connection<{ login: LoginResponse }>; + + const { result, rerender } = renderHook(() => useConnection(connection)); + rerender(); + + expect(result.current[1]).toStrictEqual({ login: null }); + // The rerender doesn't cause an additional call to either function because the input (the + // redux store) didn't change. + expect(selector).toHaveBeenCalledTimes(1); + expect(payloadCreator).toHaveBeenCalledTimes(1); + + const token = "asdfasdfasdf"; + const data = { type: "logins", id: "1", token } as JsonApiResource; + act(() => { + ApiSlice.fetchSucceeded({ url: "/foo", method: "POST", response: { data } }); + }); + + // The store has changed so the selector gets called again, and the selector's result has + // changed so the payload creator gets called again, and returns the new Login response that + // was saved in the store. + expect(result.current[1]).toStrictEqual({ login: data }); + expect(selector).toHaveBeenCalledTimes(2); + expect(payloadCreator).toHaveBeenCalledTimes(2); + + rerender(); + // The store didn't change, so neither function gets called. + expect(selector).toHaveBeenCalledTimes(2); + expect(payloadCreator).toHaveBeenCalledTimes(2); + + ApiSlice.fetchStarting({ url: "/bar", method: "POST" }); + // The store has changed, so the selector gets called again, but the selector's result is + // the same so the payload creator does not get called again, and returns its memoized result. + expect(selector).toHaveBeenCalledTimes(3); + expect(payloadCreator).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/store/store.ts b/src/store/store.ts index 5b8da3850..1cc3c081c 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -9,7 +9,7 @@ export const makeStore = (authToken?: string) => { api: apiSlice.reducer }, middleware: getDefaultMiddleware => { - if (process.env.NODE_ENV === "production") { + if (process.env.NODE_ENV === "production" || process.env.NODE_ENV === "test") { return getDefaultMiddleware().prepend(authListenerMiddleware.middleware); } else { return getDefaultMiddleware().prepend(authListenerMiddleware.middleware).concat(logger); From 6d9e0fbfb409e872ec92539cb748c8481365043c Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 23 Sep 2024 13:57:57 -0700 Subject: [PATCH 051/102] [TM-1272] Document the steps needed when adding a new service or resource. (cherry picked from commit 28679a0050f140dc60e1afb3d469a9049b25e1a1) --- README.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9900b4d37..914d6bbdc 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ To generate Api types/queries/fetchers/hooks, this repo uses: #### Usage -In order to generate from the api (whenever there is some backend endpoint/type change) please use this command: +In order to generate from the v1/2 api (whenever there is some backend endpoint/type change) please use this command: ``` yarn generate:api @@ -35,6 +35,30 @@ We can customize the `baseUrl` of where we are fetching from by changing the `co This is super useful if we want to globally set some headers for each request (such as Authorization header). It exposes a component hook so we can use other hooks (such as Auth hook or context) to get the logged in token for example and inject it in the global request context. +##### v3 API +The V3 API has a different API layer, but the generation is similar: +``` +yarn generate:services +``` + +When adding a new **service** app to the v3 API, a few steps are needed to integrate it: +* In `openapi-codegen.config.ts`, add the new service name to the `SERVICES` array (e.g. `foo-service`). +* This will generate a new target, which needs to be added to `package.json`: + * Under scripts, add `"generate:fooService": "npm run generate:fooService"` + * Under the `"generate:services"` script, add the new service: `"generate:services": "npm run generate:userService && npm run generate:fooService` +* After running `yarn generate:fooService` the first time, open the generated `fooServiceFetcher.ts` and + modify it to match `userServiceFetcher.ts`. + * This file does not get regenerated after the first time, and so it can utilize the same utilities + for interfacing with the redux API layer / connection system that the other v3 services use. + +When adding a new **resource** to the v3 API, a couple of steps are needed to integrate it: +* The resource needs to be specified in shape of the redux API store. In `apiSlice.ts`, add the new + resource plural name (the `type` returned in the API responses) to the store by adding it to the + `RESOURCES` const. This will make sure it's listed in the type of the ApiStore so that resources that match that type are seamlessly folded into the store cache structure. +* The shape of the resource should be specified by the auto-generated API. This type needs to be + added to the `ApiResource` type in `apiSlice.ts`. This allows us to have strongly typed results + coming from the redux APi store. + ## Translation ([Transifex Native SDK](https://developers.transifex.com/docs/native)). Transifex native sdk provides a simple solution for internationalization. From 681dfd12b665fdf53bdde9a45dc23ba1b72c392d Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 23 Sep 2024 14:03:21 -0700 Subject: [PATCH 052/102] [TM-1272] Catch the generator up with some changes that happened to the redux layer. (cherry picked from commit 1b9db6e2cd90e05de86be301717a63ec8b918894) --- openapi-codegen.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 78ed5ce64..37b7f3798 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -151,8 +151,8 @@ const generatePendingPredicates = async (context: Context, config: ConfigBase) = await context.writeFile( filename + ".ts", printNodes([ - createNamedImport(["ApiDataStore"], "@/types/connection"), - createNamedImport(["isFetching", "apiFetchFailed"], `../utils`), + createNamedImport(["isFetching", "fetchFailed"], `../utils`), + createNamedImport(["ApiDataStore"], "@/store/apiSlice"), ...(componentImports.length == 0 ? [] : [createNamedImport(componentImports, `./${formatFilename(filenamePrefix + "-components")}`)]), @@ -190,7 +190,7 @@ const createPredicateNodes = ({ ); nodes.push( - ...["isFetching", "apiFetchFailed"].map(fnName => + ...["isFetching", "fetchFailed"].map(fnName => f.createVariableStatement( [f.createModifier(ts.SyntaxKind.ExportKeyword)], f.createVariableDeclarationList( From 189bcf0fcc510577100479ced462bf22e87a34b9 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 23 Sep 2024 14:24:29 -0700 Subject: [PATCH 053/102] [TM-1272] Add a link to the new Connections documentation. (cherry picked from commit 19342a91d538b08b4ad2c075899a5d6862489656) --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 914d6bbdc..617be089b 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,11 @@ When adding a new **resource** to the v3 API, a couple of steps are needed to in added to the `ApiResource` type in `apiSlice.ts`. This allows us to have strongly typed results coming from the redux APi store. +### Connections +Connections are a **declarative** way for components to get access to the data from the cached API +layer that they need. This system is under development, and the current documentation about it is +[available in Confluence](https://gfw.atlassian.net/wiki/spaces/TerraMatch/pages/1423147024/Connections) + ## Translation ([Transifex Native SDK](https://developers.transifex.com/docs/native)). Transifex native sdk provides a simple solution for internationalization. From db3538b4c38f332f278fd5bf57d286433b29582f Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 23 Sep 2024 22:25:35 -0700 Subject: [PATCH 054/102] [TM-1272] Specs for the useConnection hook. (cherry picked from commit a044bdd50704ae674adedd0f5e5b89ac50716763) --- ...nection.test.ts => useConnection.test.tsx} | 19 ++++++++++--------- src/hooks/useConnection.ts | 8 +++++--- 2 files changed, 15 insertions(+), 12 deletions(-) rename src/hooks/{useConnection.test.ts => useConnection.test.tsx} (84%) diff --git a/src/hooks/useConnection.test.ts b/src/hooks/useConnection.test.tsx similarity index 84% rename from src/hooks/useConnection.test.ts rename to src/hooks/useConnection.test.tsx index f60ea6c32..ce7e92b60 100644 --- a/src/hooks/useConnection.test.ts +++ b/src/hooks/useConnection.test.tsx @@ -1,18 +1,17 @@ import { renderHook } from "@testing-library/react"; +import { ReactNode } from "react"; import { act } from "react-dom/test-utils"; import { createSelector } from "reselect"; import { LoginResponse } from "@/generated/v3/userService/userServiceSchemas"; import { useConnection } from "@/hooks/useConnection"; import ApiSlice, { ApiDataStore, JsonApiResource } from "@/store/apiSlice"; -import { makeStore } from "@/store/store"; +import StoreProvider from "@/store/StoreProvider"; import { Connection } from "@/types/connection"; -describe("Test useConnection hook", () => { - beforeEach(() => { - ApiSlice.store = makeStore(); - }); +const StoreWrapper = ({ children }: { children: ReactNode }) => {children}; +describe("Test useConnection hook", () => { test("isLoaded", () => { const load = jest.fn(); let connectionLoaded = false; @@ -21,14 +20,14 @@ describe("Test useConnection hook", () => { load, isLoaded: ({ connectionLoaded }) => connectionLoaded } as Connection<{ connectionLoaded: boolean }>; - let rendered = renderHook(() => useConnection(connection)); + let rendered = renderHook(() => useConnection(connection), { wrapper: StoreWrapper }); expect(rendered.result.current[0]).toBe(false); expect(load).toHaveBeenCalled(); load.mockReset(); connectionLoaded = true; - rendered = renderHook(() => useConnection(connection)); + rendered = renderHook(() => useConnection(connection), { wrapper: StoreWrapper }); expect(rendered.result.current[0]).toBe(true); expect(load).toHaveBeenCalled(); }); @@ -43,7 +42,7 @@ describe("Test useConnection hook", () => { selector: createSelector([selector], payloadCreator) } as Connection<{ login: LoginResponse }>; - const { result, rerender } = renderHook(() => useConnection(connection)); + const { result, rerender } = renderHook(() => useConnection(connection), { wrapper: StoreWrapper }); rerender(); expect(result.current[1]).toStrictEqual({ login: null }); @@ -70,7 +69,9 @@ describe("Test useConnection hook", () => { expect(selector).toHaveBeenCalledTimes(2); expect(payloadCreator).toHaveBeenCalledTimes(2); - ApiSlice.fetchStarting({ url: "/bar", method: "POST" }); + act(() => { + ApiSlice.fetchStarting({ url: "/bar", method: "POST" }); + }); // The store has changed, so the selector gets called again, but the selector's result is // the same so the payload creator does not get called again, and returns its memoized result. expect(selector).toHaveBeenCalledTimes(3); diff --git a/src/hooks/useConnection.ts b/src/hooks/useConnection.ts index e03552671..b1b6152c5 100644 --- a/src/hooks/useConnection.ts +++ b/src/hooks/useConnection.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; +import { useStore } from "react-redux"; -import ApiSlice from "@/store/apiSlice"; +import { AppStore } from "@/store/store"; import { Connected, Connection, OptionalProps } from "@/types/connection"; /** @@ -15,9 +16,10 @@ export function useConnection = {} ): Connected { const { selector, isLoaded, load } = connection; + const store = useStore(); const getConnected = useCallback(() => { - const connected = selector(ApiSlice.store.getState().api, props); + const connected = selector(store.getState().api, props); const loadingDone = isLoaded == null || isLoaded(connected, props); return { loadingDone, connected }; }, [isLoaded, props, selector]); @@ -40,7 +42,7 @@ export function useConnection Date: Mon, 23 Sep 2024 22:41:14 -0700 Subject: [PATCH 055/102] [TM-1272] Get storybook and test:ci fully working. (cherry picked from commit bec351ca4ac871365e398dcd78c02f471202cac1) --- .storybook/preview.js | 16 ++++++++++++++++ .../generic/Navbar/Navbar.stories.tsx | 19 +++++++++++-------- src/hooks/useConnection.ts | 2 +- src/store/StoreProvider.tsx | 3 ++- src/store/store.ts | 12 +++++++----- 5 files changed, 37 insertions(+), 15 deletions(-) diff --git a/.storybook/preview.js b/.storybook/preview.js index a5e2d6fba..f9f8b88a2 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,5 +1,6 @@ import "src/styles/globals.css"; import * as NextImage from "next/image"; +import StoreProvider from "../src/store/StoreProvider"; export const parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, @@ -24,3 +25,18 @@ Object.defineProperty(NextImage, "default", { /> ) }); + +export const decorators = [ + (Story, options) => { + const { parameters } = options; + + let storeProviderProps = {}; + if (parameters.storeProviderProps != null) { + storeProviderProps = parameters.storeProviderProps; + } + + return + + ; + }, +]; diff --git a/src/components/generic/Navbar/Navbar.stories.tsx b/src/components/generic/Navbar/Navbar.stories.tsx index 8a6659507..4e9dbaeb5 100644 --- a/src/components/generic/Navbar/Navbar.stories.tsx +++ b/src/components/generic/Navbar/Navbar.stories.tsx @@ -13,21 +13,24 @@ type Story = StoryObj; const client = new QueryClient(); export const LoggedIn: Story = { + parameters: { + storeProviderProps: { authToken: "fakeauthtoken" } + }, decorators: [ Story => ( ) - ], - args: { - isLoggedIn: true - } + ] }; export const LoggedOut: Story = { - ...LoggedIn, - args: { - isLoggedIn: false - } + decorators: [ + Story => ( + + + + ) + ] }; diff --git a/src/hooks/useConnection.ts b/src/hooks/useConnection.ts index b1b6152c5..8b31f294b 100644 --- a/src/hooks/useConnection.ts +++ b/src/hooks/useConnection.ts @@ -22,7 +22,7 @@ export function useConnection { const { loadingDone, connected } = getConnected(); diff --git a/src/store/StoreProvider.tsx b/src/store/StoreProvider.tsx index dbd55e95f..ad6f3e84d 100644 --- a/src/store/StoreProvider.tsx +++ b/src/store/StoreProvider.tsx @@ -1,6 +1,7 @@ "use client"; import { useRef } from "react"; import { Provider } from "react-redux"; +import { Store } from "redux"; import { AppStore, makeStore } from "./store"; @@ -11,7 +12,7 @@ export default function StoreProvider({ authToken?: string; children: React.ReactNode; }) { - const storeRef = useRef(); + const storeRef = useRef>(); if (!storeRef.current) { // Create the store instance the first time this renders storeRef.current = makeStore(authToken); diff --git a/src/store/store.ts b/src/store/store.ts index 1cc3c081c..b9f78bf74 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,9 +1,14 @@ import { configureStore } from "@reduxjs/toolkit"; +import { Store } from "redux"; import { logger } from "redux-logger"; -import ApiSlice, { apiSlice, authListenerMiddleware } from "@/store/apiSlice"; +import ApiSlice, { ApiDataStore, apiSlice, authListenerMiddleware } from "@/store/apiSlice"; -export const makeStore = (authToken?: string) => { +export type AppStore = { + api: ApiDataStore; +}; + +export const makeStore = (authToken?: string): Store => { const store = configureStore({ reducer: { api: apiSlice.reducer @@ -25,6 +30,3 @@ export const makeStore = (authToken?: string) => { return store; }; - -// Infer the type of makeStore -export type AppStore = ReturnType; From a2b1ad1040e4f011ca3260eab1b584c51bb58adb Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 26 Sep 2024 21:13:42 -0700 Subject: [PATCH 056/102] [TM-1312] Adapt to the more robust JSON:API shape the v3 BE is sending now. (cherry picked from commit 08f6b231c6018a08c24fb91a6aca76f1907d58da) --- src/admin/apiProvider/authProvider.ts | 27 +---- src/connections/Login.ts | 5 +- .../v3/userService/userServiceComponents.ts | 112 +++++++++++++++++- .../v3/userService/userServicePredicates.ts | 7 ++ .../v3/userService/userServiceSchemas.ts | 48 ++++++-- src/store/apiSlice.ts | 50 ++++++-- 6 files changed, 199 insertions(+), 50 deletions(-) diff --git a/src/admin/apiProvider/authProvider.ts b/src/admin/apiProvider/authProvider.ts index 90922d014..db9b62df2 100644 --- a/src/admin/apiProvider/authProvider.ts +++ b/src/admin/apiProvider/authProvider.ts @@ -1,13 +1,13 @@ import { AuthProvider } from "react-admin"; -import { fetchGetAuthLogout, fetchGetAuthMe } from "@/generated/apiComponents"; +import { fetchGetAuthLogout } from "@/generated/apiComponents"; import Log from "@/utils/log"; import { AdminTokenStorageKey, removeAccessToken } from "./utils/token"; export const authProvider: AuthProvider = { // send username and password to the auth server and get back credentials - login: async params => { + login: async () => { Log.error("Admin app does not support direct login"); }, @@ -17,7 +17,7 @@ export const authProvider: AuthProvider = { }, // when the user navigates, make sure that their credentials are still valid - checkAuth: async params => { + checkAuth: async () => { const token = localStorage.getItem(AdminTokenStorageKey); if (!token) return Promise.reject(); @@ -51,27 +51,6 @@ export const authProvider: AuthProvider = { }); }, - // get the user's profile - getIdentity: async () => { - const token = localStorage.getItem(AdminTokenStorageKey); - if (!token) return Promise.reject(); - - return new Promise((resolve, reject) => { - fetchGetAuthMe({}) - .then(response => { - //@ts-ignore - const userData = response.data; - resolve({ - ...userData, - fullName: `${userData.first_name} ${userData.last_name}` - }); - }) - .catch(() => { - reject(); - }); - }); - }, - // get the user permissions (optional) getPermissions: () => { return Promise.resolve(); diff --git a/src/connections/Login.ts b/src/connections/Login.ts index fbaaa1fc1..8684a09a3 100644 --- a/src/connections/Login.ts +++ b/src/connections/Login.ts @@ -21,10 +21,7 @@ export const logout = () => { ApiSlice.clearApiCache(); }; -const selectFirstLogin = (state: ApiDataStore) => { - const values = Object.values(state.logins); - return values.length < 1 ? null : values[0]; -}; +const selectFirstLogin = (state: ApiDataStore) => Object.values(state.logins)?.[0]?.attributes; export const loginConnection: Connection = { selector: createSelector( diff --git a/src/generated/v3/userService/userServiceComponents.ts b/src/generated/v3/userService/userServiceComponents.ts index cd96b66da..38fcb11c5 100644 --- a/src/generated/v3/userService/userServiceComponents.ts +++ b/src/generated/v3/userService/userServiceComponents.ts @@ -18,11 +18,25 @@ export type AuthLoginError = Fetcher.ErrorWrapper<{ * @example Unauthorized */ message: string; + /** + * @example Unauthorized + */ + error?: string; }; }>; export type AuthLoginResponse = { - data?: Schemas.LoginResponse; + data?: { + /** + * @example logins + */ + type?: string; + /** + * @pattern ^\d{5}$ + */ + id?: string; + attributes?: Schemas.LoginDto; + }; }; export type AuthLoginVariables = { @@ -39,3 +53,99 @@ export const authLogin = (variables: AuthLoginVariables, signal?: AbortSignal) = ...variables, signal }); + +export type UsersFindPathParams = { + id: string; +}; + +export type UsersFindError = Fetcher.ErrorWrapper< + | { + status: 401; + payload: { + /** + * @example 401 + */ + statusCode: number; + /** + * @example Unauthorized + */ + message: string; + /** + * @example Unauthorized + */ + error?: string; + }; + } + | { + status: 404; + payload: { + /** + * @example 404 + */ + statusCode: number; + /** + * @example Not Found + */ + message: string; + /** + * @example Not Found + */ + error?: string; + }; + } +>; + +export type UsersFindResponse = { + data?: { + /** + * @example users + */ + type?: string; + /** + * @format uuid + */ + id?: string; + attributes?: Schemas.UserDto; + relationships?: { + org?: { + /** + * @example organisations + */ + type?: string; + /** + * @format uuid + */ + id?: string; + meta?: { + userStatus?: "approved" | "requested" | "rejected"; + }; + }; + }; + }; + included?: { + /** + * @example organisations + */ + type?: string; + /** + * @format uuid + */ + id?: string; + attributes?: Schemas.OrganisationDto; + }[]; +}; + +export type UsersFindVariables = { + pathParams: UsersFindPathParams; +}; + +/** + * Fetch a user by ID, or with the 'me' identifier + */ +export const usersFind = (variables: UsersFindVariables, signal?: AbortSignal) => + userServiceFetch({ + url: "/users/v3/users/{id}", + method: "get", + ...variables, + signal + }); diff --git a/src/generated/v3/userService/userServicePredicates.ts b/src/generated/v3/userService/userServicePredicates.ts index a79ea180b..e20c5f4de 100644 --- a/src/generated/v3/userService/userServicePredicates.ts +++ b/src/generated/v3/userService/userServicePredicates.ts @@ -1,8 +1,15 @@ import { isFetching, fetchFailed } from "../utils"; import { ApiDataStore } from "@/store/apiSlice"; +import { UsersFindPathParams, UsersFindVariables } from "./userServiceComponents"; export const authLoginIsFetching = (state: ApiDataStore) => isFetching<{}, {}>({ state, url: "/auth/v3/logins", method: "post" }); export const authLoginFetchFailed = (state: ApiDataStore) => fetchFailed<{}, {}>({ state, url: "/auth/v3/logins", method: "post" }); + +export const usersFindIsFetching = (state: ApiDataStore, variables: UsersFindVariables) => + isFetching<{}, UsersFindPathParams>({ state, url: "/users/v3/users/{id}", method: "get", ...variables }); + +export const usersFindFetchFailed = (state: ApiDataStore, variables: UsersFindVariables) => + fetchFailed<{}, UsersFindPathParams>({ state, url: "/users/v3/users/{id}", method: "get", ...variables }); diff --git a/src/generated/v3/userService/userServiceSchemas.ts b/src/generated/v3/userService/userServiceSchemas.ts index c95b87215..59c257689 100644 --- a/src/generated/v3/userService/userServiceSchemas.ts +++ b/src/generated/v3/userService/userServiceSchemas.ts @@ -3,17 +3,7 @@ * * @version 1.0 */ -export type LoginResponse = { - /** - * @example logins - */ - type: string; - /** - * The ID of the user associated with this login - * - * @example 1234 - */ - id: string; +export type LoginDto = { /** * JWT token for use in future authenticated requests to the API. * @@ -26,3 +16,39 @@ export type LoginRequest = { emailAddress: string; password: string; }; + +export type UserFramework = { + /** + * @example TerraFund Landscapes + */ + name: string; + /** + * @example terrafund-landscapes + */ + slug: string; +}; + +export type UserDto = { + firstName: string; + lastName: string; + /** + * Currently just calculated by appending lastName to firstName. + */ + fullName: string; + primaryRole: string; + /** + * @example person@foocorp.net + */ + emailAddress: string; + /** + * @format date-time + */ + emailAddressVerifiedAt: string; + locale: string; + frameworks: UserFramework[]; +}; + +export type OrganisationDto = { + status: "draft" | "pending" | "approved" | "rejected"; + name: string; +}; diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index f11b36c83..f932f453e 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -3,7 +3,7 @@ import { isArray } from "lodash"; import { Store } from "redux"; import { setAccessToken } from "@/admin/apiProvider/utils/token"; -import { LoginResponse } from "@/generated/v3/userService/userServiceSchemas"; +import { LoginDto, OrganisationDto, UserDto } from "@/generated/v3/userService/userServiceSchemas"; export type PendingErrorState = { statusCode: number; @@ -25,26 +25,55 @@ export type ApiPendingStore = { [key in Method]: Record; }; +type AttributeValue = string | number | boolean; +type Attributes = { + [key: string]: AttributeValue | Attributes; +}; + +type Relationship = { + type: string; + id: string; + meta?: Attributes; +}; + +type StoreResource = { + attributes: AttributeType; + relationships?: { + [key: string]: Relationship | Relationship[]; + }; +}; + +type StoreResourceMap = Record>; + // The list of potential resource types. IMPORTANT: When a new resource type is integrated, it must // be added to this list. -export const RESOURCES = ["logins"] as const; +export const RESOURCES = ["logins", "organisations", "users"] as const; + +type ApiResources = { + logins: StoreResourceMap; + organisations: StoreResourceMap; + users: StoreResourceMap; +}; export type JsonApiResource = { type: (typeof RESOURCES)[number]; id: string; + attributes: Attributes; + relationships?: Relationship | Relationship[]; }; export type JsonApiResponse = { data: JsonApiResource[] | JsonApiResource; -}; - -type ApiResources = { - logins: Record; + included?: JsonApiResource[]; }; export type ApiDataStore = ApiResources & { meta: { + /** Stores the state of in-flight and failed requests */ pending: ApiPendingStore; + + /** Is snatched and stored by middleware when a users/me request completes. */ + meUserId?: string; }; }; @@ -96,7 +125,8 @@ export const apiSlice = createSlice({ // The data resource type is expected to match what is declared above in ApiDataStore, but // there isn't a way to enforce that with TS against this dynamic data structure, so we // use the dreaded any. - state[resource.type][resource.id] = resource as any; + const { type, id, ...rest } = resource; + state[type][id] = rest as StoreResource; } }, @@ -116,7 +146,7 @@ export const apiSlice = createSlice({ // We only ever expect there to be at most one Login in the store, and we never inspect the ID // so we can safely fake a login into the store when we have an authToken already set in a // cookie on app bootup. - state.logins["1"] = { id: "id", type: "logins", token: authToken }; + state.logins["1"] = { attributes: { token: authToken } }; } } }); @@ -134,8 +164,8 @@ authListenerMiddleware.startListening({ const { url, method, response } = action.payload; if (!url.endsWith("auth/v3/logins") || method !== "POST") return; - const { data } = response as { data: LoginResponse }; - setAccessToken(data.token); + const { token } = (response.data as JsonApiResource).attributes as LoginDto; + setAccessToken(token); } }); From 12c83276d066c12c19c71e69e25a4ac450aec747 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 26 Sep 2024 22:45:17 -0700 Subject: [PATCH 057/102] [TM-1312] Implemented a functional myUserConnection. (cherry picked from commit 28f8f241d7d29bd3f07df12513451012ac22e2ae) --- openapi-codegen.config.ts | 99 ++++++++++--------- src/admin/apiProvider/authProvider.ts | 6 +- src/admin/apiProvider/utils/token.ts | 14 +-- .../modules/form/components/CloneForm.tsx | 6 +- src/connections/Login.ts | 2 +- src/connections/User.ts | 33 +++++++ src/generated/apiFetcher.ts | 8 +- .../v3/userService/userServiceFetcher.ts | 63 +----------- .../v3/userService/userServicePredicates.ts | 16 +-- src/generated/v3/utils.ts | 80 +++++++++++++-- src/hooks/useUserData.ts | 4 + src/store/apiSlice.ts | 41 +++++--- src/store/store.ts | 2 +- 13 files changed, 223 insertions(+), 151 deletions(-) create mode 100644 src/connections/User.ts diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 37b7f3798..2d97a2d6a 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -180,18 +180,65 @@ const createPredicateNodes = ({ }) => { const nodes: ts.Node[] = []; - const stateTypeDeclaration = f.createParameterDeclaration( + const storeTypeDeclaration = f.createParameterDeclaration( undefined, undefined, - f.createIdentifier("state"), + f.createIdentifier("store"), undefined, f.createTypeReferenceNode("ApiDataStore"), undefined ); nodes.push( - ...["isFetching", "fetchFailed"].map(fnName => - f.createVariableStatement( + ...["isFetching", "fetchFailed"].map(fnName => { + const callBaseSelector = f.createCallExpression( + f.createIdentifier(fnName), + [queryParamsType, pathParamsType], + [ + f.createObjectLiteralExpression( + [ + f.createShorthandPropertyAssignment("store"), + f.createPropertyAssignment(f.createIdentifier("url"), f.createStringLiteral(camelizedPathParams(url))), + f.createPropertyAssignment(f.createIdentifier("method"), f.createStringLiteral(verb)), + ...(variablesType.kind !== ts.SyntaxKind.VoidKeyword + ? [f.createSpreadAssignment(f.createIdentifier("variables"))] + : []) + ], + false + ) + ] + ); + + let selector = f.createArrowFunction( + undefined, + undefined, + [storeTypeDeclaration], + undefined, + f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + callBaseSelector + ); + + if (variablesType.kind !== ts.SyntaxKind.VoidKeyword) { + selector = f.createArrowFunction( + undefined, + undefined, + [ + f.createParameterDeclaration( + undefined, + undefined, + f.createIdentifier("variables"), + undefined, + variablesType, + undefined + ) + ], + undefined, + f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + selector + ); + } + + return f.createVariableStatement( [f.createModifier(ts.SyntaxKind.ExportKeyword)], f.createVariableDeclarationList( [ @@ -199,51 +246,13 @@ const createPredicateNodes = ({ f.createIdentifier(`${name}${_.upperFirst(fnName)}`), undefined, undefined, - f.createArrowFunction( - undefined, - undefined, - variablesType.kind !== ts.SyntaxKind.VoidKeyword - ? [ - stateTypeDeclaration, - f.createParameterDeclaration( - undefined, - undefined, - f.createIdentifier("variables"), - undefined, - variablesType, - undefined - ) - ] - : [stateTypeDeclaration], - undefined, - f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - f.createCallExpression( - f.createIdentifier(fnName), - [queryParamsType, pathParamsType], - [ - f.createObjectLiteralExpression( - [ - f.createShorthandPropertyAssignment("state"), - f.createPropertyAssignment( - f.createIdentifier("url"), - f.createStringLiteral(camelizedPathParams(url)) - ), - f.createPropertyAssignment(f.createIdentifier("method"), f.createStringLiteral(verb)), - ...(variablesType.kind !== ts.SyntaxKind.VoidKeyword - ? [f.createSpreadAssignment(f.createIdentifier("variables"))] - : []) - ], - false - ) - ] - ) - ) + selector ) ], ts.NodeFlags.Const ) - ) - ) + ); + }) ); return nodes; diff --git a/src/admin/apiProvider/authProvider.ts b/src/admin/apiProvider/authProvider.ts index db9b62df2..d9625c8f2 100644 --- a/src/admin/apiProvider/authProvider.ts +++ b/src/admin/apiProvider/authProvider.ts @@ -3,7 +3,7 @@ import { AuthProvider } from "react-admin"; import { fetchGetAuthLogout } from "@/generated/apiComponents"; import Log from "@/utils/log"; -import { AdminTokenStorageKey, removeAccessToken } from "./utils/token"; +import { getAccessToken, removeAccessToken } from "./utils/token"; export const authProvider: AuthProvider = { // send username and password to the auth server and get back credentials @@ -18,7 +18,7 @@ export const authProvider: AuthProvider = { // when the user navigates, make sure that their credentials are still valid checkAuth: async () => { - const token = localStorage.getItem(AdminTokenStorageKey); + const token = getAccessToken(); if (!token) return Promise.reject(); // TODO (TM-1312) Once we have a connection for the users/me object, we can check the cached @@ -36,7 +36,7 @@ export const authProvider: AuthProvider = { }, // remove local credentials and notify the auth server that the user logged out logout: async () => { - const token = localStorage.getItem(AdminTokenStorageKey); + const token = getAccessToken(); if (!token) return Promise.resolve(); return new Promise(resolve => { diff --git a/src/admin/apiProvider/utils/token.ts b/src/admin/apiProvider/utils/token.ts index 80c1931d2..7867699ee 100644 --- a/src/admin/apiProvider/utils/token.ts +++ b/src/admin/apiProvider/utils/token.ts @@ -1,12 +1,14 @@ import { destroyCookie, setCookie } from "nookies"; -export const AdminTokenStorageKey = "access_token"; -export const AdminCookieStorageKey = "accessToken"; +const TOKEN_STORAGE_KEY = "access_token"; +const COOKIE_STORAGE_KEY = "accessToken"; const MiddlewareCacheKey = "middlewareCache"; +export const getAccessToken = () => localStorage.getItem(TOKEN_STORAGE_KEY); + export const setAccessToken = (token: string) => { - localStorage.setItem(AdminTokenStorageKey, token); - setCookie(null, AdminCookieStorageKey, token, { + localStorage.setItem(TOKEN_STORAGE_KEY, token); + setCookie(null, COOKIE_STORAGE_KEY, token, { maxAge: 60 * 60 * 12, // 12 hours secure: process.env.NODE_ENV !== "development", path: "/" @@ -14,8 +16,8 @@ export const setAccessToken = (token: string) => { }; export const removeAccessToken = () => { - localStorage.removeItem(AdminTokenStorageKey); - destroyCookie(null, AdminCookieStorageKey, { + localStorage.removeItem(TOKEN_STORAGE_KEY); + destroyCookie(null, COOKIE_STORAGE_KEY, { path: "/" }); destroyCookie(null, MiddlewareCacheKey, { diff --git a/src/admin/modules/form/components/CloneForm.tsx b/src/admin/modules/form/components/CloneForm.tsx index 104750f2c..e91755ea1 100644 --- a/src/admin/modules/form/components/CloneForm.tsx +++ b/src/admin/modules/form/components/CloneForm.tsx @@ -4,7 +4,7 @@ import { useNotify, useRecordContext } from "react-admin"; import { useForm } from "react-hook-form"; import { normalizeFormCreatePayload } from "@/admin/apiProvider/dataNormalizers/formDataNormalizer"; -import { AdminTokenStorageKey } from "@/admin/apiProvider/utils/token"; +import { getAccessToken } from "@/admin/apiProvider/utils/token"; import { appendAdditionalFormQuestionFields } from "@/admin/modules/form/components/FormBuilder/QuestionArrayInput"; import Input from "@/components/elements/Inputs/Input/Input"; import { fetchGetV2FormsLinkedFieldListing } from "@/generated/apiComponents"; @@ -12,7 +12,7 @@ import { fetchGetV2FormsLinkedFieldListing } from "@/generated/apiComponents"; export const CloneForm = () => { const record: any = useRecordContext(); const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; - const token = localStorage.getItem(AdminTokenStorageKey); + const token = getAccessToken(); const [open, setOpen] = useState(false); const notify = useNotify(); const formHook = useForm({ @@ -90,7 +90,7 @@ export const CloneForm = () => { - diff --git a/src/connections/Login.ts b/src/connections/Login.ts index 8684a09a3..558020461 100644 --- a/src/connections/Login.ts +++ b/src/connections/Login.ts @@ -21,7 +21,7 @@ export const logout = () => { ApiSlice.clearApiCache(); }; -const selectFirstLogin = (state: ApiDataStore) => Object.values(state.logins)?.[0]?.attributes; +const selectFirstLogin = (store: ApiDataStore) => Object.values(store.logins)?.[0]?.attributes; export const loginConnection: Connection = { selector: createSelector( diff --git a/src/connections/User.ts b/src/connections/User.ts new file mode 100644 index 000000000..a9479d591 --- /dev/null +++ b/src/connections/User.ts @@ -0,0 +1,33 @@ +import { createSelector } from "reselect"; + +import { usersFind, UsersFindVariables } from "@/generated/v3/userService/userServiceComponents"; +import { usersFindFetchFailed } from "@/generated/v3/userService/userServicePredicates"; +import { UserDto } from "@/generated/v3/userService/userServiceSchemas"; +import { ApiDataStore, Relationships } from "@/store/apiSlice"; +import { Connection } from "@/types/connection"; + +type UserConnection = { + user?: UserDto; + userRelationships?: Relationships; + userLoadFailed: boolean; +}; + +const selectMeId = (store: ApiDataStore) => store.meta.meUserId; +const selectUsers = (store: ApiDataStore) => store.users; +const selectMe = createSelector([selectMeId, selectUsers], (meId, users) => (meId == null ? undefined : users?.[meId])); + +const FIND_ME: UsersFindVariables = { pathParams: { id: "me" } }; + +export const myUserConnection: Connection = { + load: ({ user }) => { + if (user == null) usersFind(FIND_ME); + }, + + isLoaded: ({ user, userLoadFailed }) => userLoadFailed || user != null, + + selector: createSelector([selectMe, usersFindFetchFailed(FIND_ME)], (resource, userLoadFailure) => ({ + user: resource?.attributes, + userRelationships: resource?.relationships, + userLoadFailed: userLoadFailure != null + })) +}; diff --git a/src/generated/apiFetcher.ts b/src/generated/apiFetcher.ts index ddd7c77b0..2b3b621f2 100644 --- a/src/generated/apiFetcher.ts +++ b/src/generated/apiFetcher.ts @@ -1,4 +1,4 @@ -import { AdminTokenStorageKey } from "../admin/apiProvider/utils/token"; +import { getAccessToken } from "../admin/apiProvider/utils/token"; import { ApiContext } from "./apiContext"; import FormData from "form-data"; import Log from "@/utils/log"; @@ -56,10 +56,10 @@ export async function apiFetch< ...headers }; - const adminToken = typeof window !== "undefined" && localStorage.getItem(AdminTokenStorageKey); + const accessToken = typeof window !== "undefined" && getAccessToken(); - if (!requestHeaders?.Authorization && adminToken) { - requestHeaders.Authorization = `Bearer ${adminToken}`; + if (!requestHeaders?.Authorization && accessToken) { + requestHeaders.Authorization = `Bearer ${accessToken}`; } /** diff --git a/src/generated/v3/userService/userServiceFetcher.ts b/src/generated/v3/userService/userServiceFetcher.ts index 46bfad6e4..add02a4ee 100644 --- a/src/generated/v3/userService/userServiceFetcher.ts +++ b/src/generated/v3/userService/userServiceFetcher.ts @@ -1,65 +1,6 @@ -import { dispatchRequest, resolveUrl } from "@/generated/v3/utils"; - // This type is imported in the auto generated `userServiceComponents` file, so it needs to be // exported from this file. export type { ErrorWrapper } from "../utils"; -export type UserServiceFetcherExtraProps = { - /** - * You can add some extra props to your generated fetchers. - * - * Note: You need to re-gen after adding the first property to - * have the `UserServiceFetcherExtraProps` injected in `UserServiceComponents.ts` - **/ -}; - -export type UserServiceFetcherOptions = { - url: string; - method: string; - body?: TBody; - headers?: THeaders; - queryParams?: TQueryParams; - pathParams?: TPathParams; - signal?: AbortSignal; -} & UserServiceFetcherExtraProps; - -export function userServiceFetch< - TData, - TError, - TBody extends {} | FormData | undefined | null, - THeaders extends {}, - TQueryParams extends {}, - TPathParams extends {} ->({ - url, - method, - body, - headers, - pathParams, - queryParams, - signal -}: UserServiceFetcherOptions) { - const requestHeaders: HeadersInit = { - "Content-Type": "application/json", - ...headers - }; - - /** - * As the fetch API is being used, when multipart/form-data is specified - * the Content-Type header must be deleted so that the browser can set - * the correct boundary. - * https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object - */ - if (requestHeaders["Content-Type"].toLowerCase().includes("multipart/form-data")) { - delete requestHeaders["Content-Type"]; - } - - // The promise is ignored on purpose. Further progress of the request is tracked through - // redux. - dispatchRequest(resolveUrl(url, queryParams, pathParams), { - signal, - method: method.toUpperCase(), - body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined, - headers: requestHeaders - }); -} +// The serviceFetch method is the shared fetch method for all service fetchers. +export { serviceFetch as userServiceFetch } from "../utils"; diff --git a/src/generated/v3/userService/userServicePredicates.ts b/src/generated/v3/userService/userServicePredicates.ts index e20c5f4de..16771c4b5 100644 --- a/src/generated/v3/userService/userServicePredicates.ts +++ b/src/generated/v3/userService/userServicePredicates.ts @@ -2,14 +2,14 @@ import { isFetching, fetchFailed } from "../utils"; import { ApiDataStore } from "@/store/apiSlice"; import { UsersFindPathParams, UsersFindVariables } from "./userServiceComponents"; -export const authLoginIsFetching = (state: ApiDataStore) => - isFetching<{}, {}>({ state, url: "/auth/v3/logins", method: "post" }); +export const authLoginIsFetching = (store: ApiDataStore) => + isFetching<{}, {}>({ store, url: "/auth/v3/logins", method: "post" }); -export const authLoginFetchFailed = (state: ApiDataStore) => - fetchFailed<{}, {}>({ state, url: "/auth/v3/logins", method: "post" }); +export const authLoginFetchFailed = (store: ApiDataStore) => + fetchFailed<{}, {}>({ store, url: "/auth/v3/logins", method: "post" }); -export const usersFindIsFetching = (state: ApiDataStore, variables: UsersFindVariables) => - isFetching<{}, UsersFindPathParams>({ state, url: "/users/v3/users/{id}", method: "get", ...variables }); +export const usersFindIsFetching = (variables: UsersFindVariables) => (store: ApiDataStore) => + isFetching<{}, UsersFindPathParams>({ store, url: "/users/v3/users/{id}", method: "get", ...variables }); -export const usersFindFetchFailed = (state: ApiDataStore, variables: UsersFindVariables) => - fetchFailed<{}, UsersFindPathParams>({ state, url: "/users/v3/users/{id}", method: "get", ...variables }); +export const usersFindFetchFailed = (variables: UsersFindVariables) => (store: ApiDataStore) => + fetchFailed<{}, UsersFindPathParams>({ store, url: "/users/v3/users/{id}", method: "get", ...variables }); diff --git a/src/generated/v3/utils.ts b/src/generated/v3/utils.ts index 25905b80f..4d8450871 100644 --- a/src/generated/v3/utils.ts +++ b/src/generated/v3/utils.ts @@ -1,12 +1,13 @@ import ApiSlice, { ApiDataStore, isErrorState, isInProgress, Method, PendingErrorState } from "@/store/apiSlice"; import Log from "@/utils/log"; +import { getAccessToken } from "@/admin/apiProvider/utils/token"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; export type ErrorWrapper = TError | { statusCode: -1; message: string }; type SelectorOptions = { - state: ApiDataStore; + store: ApiDataStore; url: string; method: string; queryParams?: TQueryParams; @@ -29,30 +30,32 @@ export const resolveUrl = ( }; export function isFetching({ - state, + store, url, method, pathParams, queryParams }: SelectorOptions): boolean { const fullUrl = resolveUrl(url, queryParams, pathParams); - const pending = state.meta.pending[method.toUpperCase() as Method][fullUrl]; + const pending = store.meta.pending[method.toUpperCase() as Method][fullUrl]; return isInProgress(pending); } export function fetchFailed({ - state, + store, url, method, pathParams, queryParams }: SelectorOptions): PendingErrorState | null { const fullUrl = resolveUrl(url, queryParams, pathParams); - const pending = state.meta.pending[method.toUpperCase() as Method][fullUrl]; + const pending = store.meta.pending[method.toUpperCase() as Method][fullUrl]; return isErrorState(pending) ? pending : null; } -export async function dispatchRequest(url: string, requestInit: RequestInit) { +const isPending = (method: Method, fullUrl: string) => ApiSlice.apiDataStore.meta.pending[method][fullUrl] != null; + +async function dispatchRequest(url: string, requestInit: RequestInit) { const actionPayload = { url, method: requestInit.method as Method }; ApiSlice.fetchStarting(actionPayload); @@ -82,3 +85,68 @@ export async function dispatchRequest(url: string, requestInit: R ApiSlice.fetchFailed({ ...actionPayload, error: { statusCode: -1, message } }); } } + +export type ServiceFetcherOptions = { + url: string; + method: string; + body?: TBody; + headers?: THeaders; + queryParams?: TQueryParams; + pathParams?: TPathParams; + signal?: AbortSignal; +}; + +export function serviceFetch< + TData, + TError, + TBody extends {} | FormData | undefined | null, + THeaders extends {}, + TQueryParams extends {}, + TPathParams extends {} +>({ + url, + method: methodString, + body, + headers, + pathParams, + queryParams, + signal +}: ServiceFetcherOptions) { + const fullUrl = resolveUrl(url, queryParams, pathParams); + const method = methodString.toUpperCase() as Method; + if (isPending(method, fullUrl)) { + // Ignore requests to issue an API request that is in progress or has failed without a cache + // clear. + return; + } + + const requestHeaders: HeadersInit = { + "Content-Type": "application/json", + ...headers + }; + + const accessToken = typeof window === "undefined" ? null : getAccessToken(); + if (!requestHeaders?.Authorization && accessToken != null) { + // Always include the JWT access token if we have one. + requestHeaders.Authorization = `Bearer ${accessToken}`; + } + + /** + * As the fetch API is being used, when multipart/form-data is specified + * the Content-Type header must be deleted so that the browser can set + * the correct boundary. + * https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object + */ + if (requestHeaders["Content-Type"].toLowerCase().includes("multipart/form-data")) { + delete requestHeaders["Content-Type"]; + } + + // The promise is ignored on purpose. Further progress of the request is tracked through + // redux. + dispatchRequest(fullUrl, { + signal, + method, + body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined, + headers: requestHeaders + }); +} diff --git a/src/hooks/useUserData.ts b/src/hooks/useUserData.ts index ec1db36fa..076944ab9 100644 --- a/src/hooks/useUserData.ts +++ b/src/hooks/useUserData.ts @@ -1,7 +1,9 @@ import { loginConnection } from "@/connections/Login"; +import { myUserConnection } from "@/connections/User"; import { useGetAuthMe } from "@/generated/apiComponents"; import { MeResponse } from "@/generated/apiSchemas"; import { useConnection } from "@/hooks/useConnection"; +import Log from "@/utils/log"; /** * To easily access user data @@ -12,6 +14,8 @@ import { useConnection } from "@/hooks/useConnection"; */ export const useUserData = () => { const [, { token }] = useConnection(loginConnection); + const [myUserLoading, myUserResult] = useConnection(myUserConnection); + Log.debug("myUserConnection", myUserLoading, myUserResult); const { data: authMe } = useGetAuthMe<{ data: MeResponse }>( {}, { diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index f932f453e..66c08cbd7 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -36,11 +36,13 @@ type Relationship = { meta?: Attributes; }; -type StoreResource = { +export type Relationships = { + [key: string]: Relationship | Relationship[]; +}; + +export type StoreResource = { attributes: AttributeType; - relationships?: { - [key: string]: Relationship | Relationship[]; - }; + relationships?: Relationships; }; type StoreResourceMap = Record>; @@ -121,6 +123,11 @@ export const apiSlice = createSlice({ // All response objects from the v3 api conform to JsonApiResponse let { data } = response; if (!isArray(data)) data = [data]; + if (response.included != null) { + // For the purposes of this reducer, data and included are the same: they both get merged + // into the data cache. + data = [...data, ...response.included]; + } for (const resource of data) { // The data resource type is expected to match what is declared above in ApiDataStore, but // there isn't a way to enforce that with TS against this dynamic data structure, so we @@ -128,6 +135,10 @@ export const apiSlice = createSlice({ const { type, id, ...rest } = resource; state[type][id] = rest as StoreResource; } + + if (url.endsWith("/users/me") && method === "GET") { + state.meta.meUserId = (response.data as JsonApiResource).id; + } }, clearApiCache: state => { @@ -170,29 +181,33 @@ authListenerMiddleware.startListening({ }); export default class ApiSlice { - private static _store: Store; + private static _redux: Store; + + static set redux(store: Store) { + this._redux = store; + } - static set store(store: Store) { - this._store = store; + static get redux(): Store { + return this._redux; } - static get store(): Store { - return this._store; + static get apiDataStore(): ApiDataStore { + return this.redux.getState().api; } static fetchStarting(props: ApiFetchStartingProps) { - this.store.dispatch(apiSlice.actions.apiFetchStarting(props)); + this.redux.dispatch(apiSlice.actions.apiFetchStarting(props)); } static fetchFailed(props: ApiFetchFailedProps) { - this.store.dispatch(apiSlice.actions.apiFetchFailed(props)); + this.redux.dispatch(apiSlice.actions.apiFetchFailed(props)); } static fetchSucceeded(props: ApiFetchSucceededProps) { - this.store.dispatch(apiSlice.actions.apiFetchSucceeded(props)); + this.redux.dispatch(apiSlice.actions.apiFetchSucceeded(props)); } static clearApiCache() { - this.store.dispatch(apiSlice.actions.clearApiCache()); + this.redux.dispatch(apiSlice.actions.clearApiCache()); } } diff --git a/src/store/store.ts b/src/store/store.ts index b9f78bf74..9d38ef319 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -26,7 +26,7 @@ export const makeStore = (authToken?: string): Store => { store.dispatch(apiSlice.actions.setInitialAuthToken({ authToken })); } - ApiSlice.store = store; + ApiSlice.redux = store; return store; }; From 1ae76dd8cbe773a376e75ad0fb19d4103d27499d Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 26 Sep 2024 23:33:16 -0700 Subject: [PATCH 058/102] [TM-1312] Implement useConnections (cherry picked from commit dbe7c3ebc7511d3bfb653d9c4cd866cd521c90f3) --- src/hooks/useConnection.ts | 69 +++++++++++++++++++++++++++++++++++++- src/hooks/useUserData.ts | 7 ++-- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/hooks/useConnection.ts b/src/hooks/useConnection.ts index 8b31f294b..16dd0b72b 100644 --- a/src/hooks/useConnection.ts +++ b/src/hooks/useConnection.ts @@ -1,8 +1,10 @@ -import { useCallback, useEffect, useState } from "react"; +/* eslint-disable no-redeclare */ +import { useCallback, useEffect, useRef, useState } from "react"; import { useStore } from "react-redux"; import { AppStore } from "@/store/store"; import { Connected, Connection, OptionalProps } from "@/types/connection"; +import Log from "@/utils/log"; /** * Use a connection to efficiently depend on data in the Redux store. @@ -52,3 +54,68 @@ export function useConnection( + connections: [Connection, Connection], + props?: P1 & P2 +): readonly [boolean, [S1, S2]]; +export function useConnections< + S1, + S2, + S3, + P1 extends OptionalProps, + P2 extends OptionalProps, + P3 extends OptionalProps +>(connections: [Connection, Connection], props?: P1 & P2 & P3): readonly [boolean, [S1, S2, S3]]; +export function useConnections< + S1, + S2, + S3, + S4, + P1 extends OptionalProps, + P2 extends OptionalProps, + P3 extends OptionalProps, + P4 extends OptionalProps +>( + connections: [Connection, Connection, Connection, Connection], + props?: P1 & P2 & P3 & P4 +): readonly [boolean, [S1, S2, S3, S4]]; +export function useConnections< + S1, + S2, + S3, + S4, + S5, + P1 extends OptionalProps, + P2 extends OptionalProps, + P3 extends OptionalProps, + P4 extends OptionalProps, + P5 extends OptionalProps +>( + connections: [Connection, Connection, Connection, Connection, Connection], + props?: P1 & P2 & P3 & P4 & P5 +): readonly [boolean, [S1, S2, S3, S4, S5]]; + +/** + * A convenience function to depend on multiple connections, and receive a single "loaded" flag + * for all of them. + */ +export function useConnections( + connections: Connection[], + props: Record = {} +): readonly [boolean, unknown[]] { + const numConnections = useRef(connections.length); + if (numConnections.current !== connections.length) { + // We're violating the rules of hooks by running hooks in a loop below, so let's scream about + // it extra loud if the number of connections changes. + Log.error("NUMBER OF CONNECTIONS CHANGED!", { original: numConnections.current, current: connections.length }); + } + + return connections.reduce( + ([allLoaded, connecteds], connection) => { + const [loaded, connected] = useConnection(connection, props); + return [loaded && allLoaded, [...connecteds, connected]]; + }, + [true, []] as readonly [boolean, unknown[]] + ); +} diff --git a/src/hooks/useUserData.ts b/src/hooks/useUserData.ts index 076944ab9..191867de3 100644 --- a/src/hooks/useUserData.ts +++ b/src/hooks/useUserData.ts @@ -2,7 +2,7 @@ import { loginConnection } from "@/connections/Login"; import { myUserConnection } from "@/connections/User"; import { useGetAuthMe } from "@/generated/apiComponents"; import { MeResponse } from "@/generated/apiSchemas"; -import { useConnection } from "@/hooks/useConnection"; +import { useConnections } from "@/hooks/useConnection"; import Log from "@/utils/log"; /** @@ -13,9 +13,8 @@ import Log from "@/utils/log"; * every 5 minutes for every component that uses this hook. */ export const useUserData = () => { - const [, { token }] = useConnection(loginConnection); - const [myUserLoading, myUserResult] = useConnection(myUserConnection); - Log.debug("myUserConnection", myUserLoading, myUserResult); + const [loaded, [{ token }, myUserResult]] = useConnections([loginConnection, myUserConnection]); + Log.debug("myUserConnection", loaded, myUserResult); const { data: authMe } = useGetAuthMe<{ data: MeResponse }>( {}, { From 57f411e81ab01668e026c4d91d7ebb010a420008 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 27 Sep 2024 10:41:29 -0700 Subject: [PATCH 059/102] [TM-1312] Implement connections that take props. (cherry picked from commit 75919d937d5d4dd4566699e0a590764e962398fd) --- src/connections/Organisation.ts | 35 +++++++++++++++++ src/hooks/useConnection.ts | 69 +------------------------------- src/hooks/useConnections.ts | 70 +++++++++++++++++++++++++++++++++ src/hooks/useUserData.ts | 8 +++- src/store/apiSlice.ts | 63 ++++++++++++++++++----------- src/utils/selectorCache.ts | 29 ++++++++++++++ 6 files changed, 182 insertions(+), 92 deletions(-) create mode 100644 src/connections/Organisation.ts create mode 100644 src/hooks/useConnections.ts create mode 100644 src/utils/selectorCache.ts diff --git a/src/connections/Organisation.ts b/src/connections/Organisation.ts new file mode 100644 index 000000000..2eb0db3c8 --- /dev/null +++ b/src/connections/Organisation.ts @@ -0,0 +1,35 @@ +import { createSelector } from "reselect"; + +import { OrganisationDto } from "@/generated/v3/userService/userServiceSchemas"; +import { ApiDataStore } from "@/store/apiSlice"; +import { Connection } from "@/types/connection"; +import { selectorCache } from "@/utils/selectorCache"; + +type OrganisationConnection = { + organisation?: OrganisationDto; + + /** + * Only included when this connection gets chained with a UserConnection so the meta on the + * relationship is available. + */ + userStatus?: "approved" | "rejected" | "requested"; +}; + +type OrganisationConnectionProps = { + organisationId?: string; +}; + +const organisationSelector = (organisationId?: string) => (store: ApiDataStore) => + organisationId == null ? undefined : store.organisations?.[organisationId]; + +export const organisationConnection: Connection = { + // TODO: This doesn't get a load/isLoaded until we have a v3 organisation get endpoint. For now + // we have to rely on the data that gets included in the users/me response. + selector: selectorCache( + ({ organisationId }) => organisationId ?? "", + ({ organisationId }) => + createSelector([organisationSelector(organisationId)], org => ({ + organisation: org?.attributes + })) + ) +}; diff --git a/src/hooks/useConnection.ts b/src/hooks/useConnection.ts index 16dd0b72b..8b31f294b 100644 --- a/src/hooks/useConnection.ts +++ b/src/hooks/useConnection.ts @@ -1,10 +1,8 @@ -/* eslint-disable no-redeclare */ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useStore } from "react-redux"; import { AppStore } from "@/store/store"; import { Connected, Connection, OptionalProps } from "@/types/connection"; -import Log from "@/utils/log"; /** * Use a connection to efficiently depend on data in the Redux store. @@ -54,68 +52,3 @@ export function useConnection( - connections: [Connection, Connection], - props?: P1 & P2 -): readonly [boolean, [S1, S2]]; -export function useConnections< - S1, - S2, - S3, - P1 extends OptionalProps, - P2 extends OptionalProps, - P3 extends OptionalProps ->(connections: [Connection, Connection], props?: P1 & P2 & P3): readonly [boolean, [S1, S2, S3]]; -export function useConnections< - S1, - S2, - S3, - S4, - P1 extends OptionalProps, - P2 extends OptionalProps, - P3 extends OptionalProps, - P4 extends OptionalProps ->( - connections: [Connection, Connection, Connection, Connection], - props?: P1 & P2 & P3 & P4 -): readonly [boolean, [S1, S2, S3, S4]]; -export function useConnections< - S1, - S2, - S3, - S4, - S5, - P1 extends OptionalProps, - P2 extends OptionalProps, - P3 extends OptionalProps, - P4 extends OptionalProps, - P5 extends OptionalProps ->( - connections: [Connection, Connection, Connection, Connection, Connection], - props?: P1 & P2 & P3 & P4 & P5 -): readonly [boolean, [S1, S2, S3, S4, S5]]; - -/** - * A convenience function to depend on multiple connections, and receive a single "loaded" flag - * for all of them. - */ -export function useConnections( - connections: Connection[], - props: Record = {} -): readonly [boolean, unknown[]] { - const numConnections = useRef(connections.length); - if (numConnections.current !== connections.length) { - // We're violating the rules of hooks by running hooks in a loop below, so let's scream about - // it extra loud if the number of connections changes. - Log.error("NUMBER OF CONNECTIONS CHANGED!", { original: numConnections.current, current: connections.length }); - } - - return connections.reduce( - ([allLoaded, connecteds], connection) => { - const [loaded, connected] = useConnection(connection, props); - return [loaded && allLoaded, [...connecteds, connected]]; - }, - [true, []] as readonly [boolean, unknown[]] - ); -} diff --git a/src/hooks/useConnections.ts b/src/hooks/useConnections.ts new file mode 100644 index 000000000..9a3c67dad --- /dev/null +++ b/src/hooks/useConnections.ts @@ -0,0 +1,70 @@ +/* eslint-disable no-redeclare */ +import { useRef } from "react"; + +import { useConnection } from "@/hooks/useConnection"; +import { Connection, OptionalProps } from "@/types/connection"; +import Log from "@/utils/log"; + +export function useConnections( + connections: [Connection, Connection], + props?: P1 & P2 +): readonly [boolean, [S1, S2]]; +export function useConnections< + S1, + S2, + S3, + P1 extends OptionalProps, + P2 extends OptionalProps, + P3 extends OptionalProps +>(connections: [Connection, Connection], props?: P1 & P2 & P3): readonly [boolean, [S1, S2, S3]]; +export function useConnections< + S1, + S2, + S3, + S4, + P1 extends OptionalProps, + P2 extends OptionalProps, + P3 extends OptionalProps, + P4 extends OptionalProps +>( + connections: [Connection, Connection, Connection, Connection], + props?: P1 & P2 & P3 & P4 +): readonly [boolean, [S1, S2, S3, S4]]; +export function useConnections< + S1, + S2, + S3, + S4, + S5, + P1 extends OptionalProps, + P2 extends OptionalProps, + P3 extends OptionalProps, + P4 extends OptionalProps, + P5 extends OptionalProps +>( + connections: [Connection, Connection, Connection, Connection, Connection], + props?: P1 & P2 & P3 & P4 & P5 +): readonly [boolean, [S1, S2, S3, S4, S5]]; + +/** + * A convenience hook to depend on multiple connections, and receive a single "loaded" flag for all of them. + */ +export function useConnections( + connections: Connection[], + props: Record = {} +): readonly [boolean, unknown[]] { + const numConnections = useRef(connections.length); + if (numConnections.current !== connections.length) { + // We're violating the rules of hooks by running hooks in a loop below, so let's scream about + // it extra loud if the number of connections changes. + Log.error("NUMBER OF CONNECTIONS CHANGED!", { original: numConnections.current, current: connections.length }); + } + + return connections.reduce( + ([allLoaded, connecteds], connection) => { + const [loaded, connected] = useConnection(connection, props); + return [loaded && allLoaded, [...connecteds, connected]]; + }, + [true, []] as readonly [boolean, unknown[]] + ); +} diff --git a/src/hooks/useUserData.ts b/src/hooks/useUserData.ts index 191867de3..7851fe6bb 100644 --- a/src/hooks/useUserData.ts +++ b/src/hooks/useUserData.ts @@ -1,8 +1,10 @@ import { loginConnection } from "@/connections/Login"; +import { organisationConnection } from "@/connections/Organisation"; import { myUserConnection } from "@/connections/User"; import { useGetAuthMe } from "@/generated/apiComponents"; import { MeResponse } from "@/generated/apiSchemas"; -import { useConnections } from "@/hooks/useConnection"; +import { useConnection } from "@/hooks/useConnection"; +import { useConnections } from "@/hooks/useConnections"; import Log from "@/utils/log"; /** @@ -14,7 +16,9 @@ import Log from "@/utils/log"; */ export const useUserData = () => { const [loaded, [{ token }, myUserResult]] = useConnections([loginConnection, myUserConnection]); - Log.debug("myUserConnection", loaded, myUserResult); + const organisationId = myUserResult?.userRelationships?.org?.[0]?.id; + const [, organisationResult] = useConnection(organisationConnection, { organisationId }); + Log.debug("myUserConnection", loaded, myUserResult, organisationResult); const { data: authMe } = useGetAuthMe<{ data: MeResponse }>( {}, { diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index 66c08cbd7..aeca13e63 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -1,4 +1,5 @@ import { createListenerMiddleware, createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { WritableDraft } from "immer"; import { isArray } from "lodash"; import { Store } from "redux"; @@ -37,11 +38,13 @@ type Relationship = { }; export type Relationships = { - [key: string]: Relationship | Relationship[]; + [key: string]: Relationship[]; }; export type StoreResource = { attributes: AttributeType; + // We do a bit of munging on the shape from the API, removing the intermediate "data" member, and + // ensuring there's always an array, to make consuming the data clientside a little smoother. relationships?: Relationships; }; @@ -61,7 +64,7 @@ export type JsonApiResource = { type: (typeof RESOURCES)[number]; id: string; attributes: Attributes; - relationships?: Relationship | Relationship[]; + relationships?: { [key: string]: { data: Relationship | Relationship[] } }; }; export type JsonApiResponse = { @@ -104,6 +107,19 @@ type ApiFetchSucceededProps = ApiFetchStartingProps & { response: JsonApiResponse; }; +const clearApiCache = (state: WritableDraft) => { + for (const resource of RESOURCES) { + state[resource] = {}; + } + + for (const method of METHODS) { + state.meta.pending[method] = {}; + } +}; + +const isLogin = ({ url, method }: { url: string; method: Method }) => + url.endsWith("auth/v3/logins") && method === "POST"; + export const apiSlice = createSlice({ name: "api", initialState, @@ -118,38 +134,43 @@ export const apiSlice = createSlice({ }, apiFetchSucceeded: (state, action: PayloadAction) => { const { url, method, response } = action.payload; - delete state.meta.pending[method][url]; + if (isLogin(action.payload)) { + // After a successful login, clear the entire cache; we want all mounted components to + // re-fetch their data with the new login credentials. + clearApiCache(state); + } else { + delete state.meta.pending[method][url]; + } // All response objects from the v3 api conform to JsonApiResponse - let { data } = response; + let { data, included } = response; if (!isArray(data)) data = [data]; - if (response.included != null) { + if (included != null) { // For the purposes of this reducer, data and included are the same: they both get merged // into the data cache. - data = [...data, ...response.included]; + data = [...data, ...included]; } for (const resource of data) { // The data resource type is expected to match what is declared above in ApiDataStore, but // there isn't a way to enforce that with TS against this dynamic data structure, so we // use the dreaded any. - const { type, id, ...rest } = resource; - state[type][id] = rest as StoreResource; + const { type, id, attributes, relationships: responseRelationships } = resource; + const storeResource: StoreResource = { attributes }; + if (responseRelationships != null) { + storeResource.relationships = {}; + for (const [key, { data }] of Object.entries(responseRelationships)) { + storeResource.relationships[key] = Array.isArray(data) ? data : [data]; + } + } + state[type][id] = storeResource; } - if (url.endsWith("/users/me") && method === "GET") { + if (url.endsWith("users/v3/users/me") && method === "GET") { state.meta.meUserId = (response.data as JsonApiResource).id; } }, - clearApiCache: state => { - for (const resource of RESOURCES) { - state[resource] = {}; - } - - for (const method of METHODS) { - state.meta.pending[method] = {}; - } - }, + clearApiCache, // only used during app bootup. setInitialAuthToken: (state, action: PayloadAction<{ authToken: string }>) => { @@ -172,10 +193,8 @@ authListenerMiddleware.startListening({ response: JsonApiResponse; }> ) => { - const { url, method, response } = action.payload; - if (!url.endsWith("auth/v3/logins") || method !== "POST") return; - - const { token } = (response.data as JsonApiResource).attributes as LoginDto; + if (!isLogin(action.payload)) return; + const { token } = (action.payload.response.data as JsonApiResource).attributes as LoginDto; setAccessToken(token); } }); diff --git a/src/utils/selectorCache.ts b/src/utils/selectorCache.ts new file mode 100644 index 000000000..8ea7a3b3b --- /dev/null +++ b/src/utils/selectorCache.ts @@ -0,0 +1,29 @@ +import { ApiDataStore } from "@/store/apiSlice"; +import { Selector } from "@/types/connection"; + +type PureSelector = (store: ApiDataStore) => S; + +/** + * A factory and cache pattern for creating pure selectors from the ApiDataStore. This allows + * a connection that takes a given set of props, and is likely to get called many times during the + * lifecycle of the component to ensure that its selectors aren't getting re-created on every + * render, and are therefore going to get the performance gains we want from reselect. + * + * @param keyFactory A method that returns a string representation of the hooks props + * @param selectorFactory A method that returns a pure (store-only) selector. + */ +export function selectorCache>( + keyFactory: (props: P) => string, + selectorFactory: (props: P) => PureSelector +): Selector { + const selectors = new Map>(); + + return (store: ApiDataStore, props: P) => { + const key = keyFactory(props); + let selector = selectors.get(key); + if (selector == null) { + selectors.set(key, (selector = selectorFactory(props))); + } + return selector(store); + }; +} From 37f09a7b6d129595190b7e62c86ebe3241056162 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 27 Sep 2024 13:17:48 -0700 Subject: [PATCH 060/102] [TM-1312] Get rid of v1/2 users/me access. (cherry picked from commit ab75cd925c8ebc7f94f9c48e6f6164a9d5f11b12) --- .../CommentarySection/CommentarySection.tsx | 15 +++---- .../DataTable/RHFCoreTeamLeadersTable.tsx | 6 +-- .../DataTable/RHFFundingTypeDataTable.tsx | 6 +-- .../DataTable/RHFLeadershipTeamTable.tsx | 6 +-- .../DataTable/RHFOwnershipStakeTable.tsx | 6 +-- .../extensive/Modal/ModalWithLogo.tsx | 15 +++---- .../extensive/WelcomeTour/WelcomeTour.tsx | 17 +++---- .../generic/Navbar/NavbarContent.tsx | 4 +- src/components/generic/Navbar/navbarItems.ts | 12 ++--- src/connections/Organisation.ts | 35 ++++++++++++--- src/connections/User.ts | 4 +- .../options/userFrameworksChoices.ts | 13 +++--- .../v3/userService/userServiceSchemas.ts | 1 + src/generated/v3/utils.ts | 23 +++++++--- src/hooks/useConnections.ts | 5 ++- src/hooks/useMyOrg.ts | 21 --------- src/hooks/useUserData.ts | 31 ------------- src/middleware.page.ts | 45 +++++++++---------- src/pages/form/[id]/pitch-select.page.tsx | 10 ++--- src/pages/home.page.tsx | 17 ++++--- src/pages/my-projects/index.page.tsx | 7 +-- src/pages/opportunities/index.page.tsx | 17 ++++--- src/pages/organization/create/index.page.tsx | 7 +-- .../organization/status/pending.page.tsx | 7 +-- .../organization/status/rejected.page.tsx | 7 +-- src/store/apiSlice.ts | 6 +++ src/store/store.ts | 26 ++++++++--- src/utils/loadConnection.ts | 30 +++++++++++++ 28 files changed, 218 insertions(+), 181 deletions(-) delete mode 100644 src/hooks/useMyOrg.ts delete mode 100644 src/hooks/useUserData.ts create mode 100644 src/utils/loadConnection.ts diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx index 15ec3b9e2..c935dd433 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx @@ -3,7 +3,8 @@ import { When } from "react-if"; import CommentaryBox from "@/components/elements/CommentaryBox/CommentaryBox"; import Text from "@/components/elements/Text/Text"; import Loader from "@/components/generic/Loading/Loader"; -import { useGetAuthMe } from "@/generated/apiComponents"; +import { myUserConnection } from "@/connections/User"; +import { useConnection } from "@/hooks/useConnection"; import { AuditLogEntity } from "../../../AuditLogTab/constants/types"; @@ -20,20 +21,14 @@ const CommentarySection = ({ viewCommentsList?: boolean; loading?: boolean; }) => { - const { data: authMe } = useGetAuthMe({}) as { - data: { - data: any; - first_name: string; - last_name: string; - }; - }; + const [, { user }] = useConnection(myUserConnection); return (
Send Comment = ({ } }); - const { data: authMe } = useGetAuthMe<{ data: GetAuthMeResponse }>({}); + const [, { user }] = useConnection(myUserConnection); const [commentsAuditLogData, restAuditLogData] = useMemo(() => { const commentsAuditLog: GetV2AuditStatusENTITYUUIDResponse = []; @@ -124,8 +121,8 @@ const ModalWithLogo: FC = ({
= ({ tourId, tourSteps, onFinish, onStart, onDontS const { setIsOpen: setIsNavOpen, setLinksDisabled: setNavLinksDisabled } = useNavbarContext(); const isLg = useMediaQuery("(min-width:1024px)"); - const userData = useUserData(); + const [, { user }] = useConnection(myUserConnection); - const TOUR_COMPLETED_STORAGE_KEY = `${tourId}_${TOUR_COMPLETED_KEY}_${userData?.uuid}`; + const TOUR_COMPLETED_STORAGE_KEY = `${tourId}_${TOUR_COMPLETED_KEY}_${user?.uuid}`; const TOUR_SKIPPED_STORAGE_KEY = `${tourId}_${TOUR_SKIPPED_KEY}`; const floaterProps = useMemo(() => { @@ -79,16 +80,16 @@ const WelcomeTour: FC = ({ tourId, tourSteps, onFinish, onStart, onDontS }, [closeModal, isLg, setIsNavOpen, setNavLinksDisabled]); const handleDontShowAgain = useCallback(() => { - if (userData?.uuid) { + if (user?.uuid) { localStorage.setItem(TOUR_COMPLETED_STORAGE_KEY, "true"); onDontShowAgain?.(); setModalInteracted(true); closeModal(ModalId.WELCOME_MODAL); } - }, [TOUR_COMPLETED_STORAGE_KEY, closeModal, onDontShowAgain, userData?.uuid]); + }, [TOUR_COMPLETED_STORAGE_KEY, closeModal, onDontShowAgain, user?.uuid]); useEffect(() => { - const userId = userData?.uuid?.toString(); + const userId = user?.uuid?.toString(); if (userId) { const isSkipped = sessionStorage.getItem(TOUR_SKIPPED_STORAGE_KEY) === "true"; const isCompleted = localStorage.getItem(TOUR_COMPLETED_STORAGE_KEY) === "true"; @@ -105,7 +106,7 @@ const WelcomeTour: FC = ({ tourId, tourSteps, onFinish, onStart, onDontS } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hasWelcomeModal, modalInteracted, userData?.uuid]); + }, [hasWelcomeModal, modalInteracted, user?.uuid]); useEffect(() => { if (tourEnabled) { @@ -134,7 +135,7 @@ const WelcomeTour: FC = ({ tourId, tourSteps, onFinish, onStart, onDontS } }} callback={data => { - if (data.status === "finished" && userData?.uuid) { + if (data.status === "finished" && user?.uuid) { localStorage.setItem(TOUR_COMPLETED_STORAGE_KEY, "true"); setTourEnabled(false); setNavLinksDisabled?.(false); diff --git a/src/components/generic/Navbar/NavbarContent.tsx b/src/components/generic/Navbar/NavbarContent.tsx index 2ce838f26..12f2ef7d1 100644 --- a/src/components/generic/Navbar/NavbarContent.tsx +++ b/src/components/generic/Navbar/NavbarContent.tsx @@ -8,10 +8,10 @@ import LanguagesDropdown from "@/components/elements/Inputs/LanguageDropdown/Lan import { IconNames } from "@/components/extensive/Icon/Icon"; import List from "@/components/extensive/List/List"; import { loginConnection } from "@/connections/Login"; +import { myOrganisationConnection } from "@/connections/Organisation"; import { useNavbarContext } from "@/context/navbar.provider"; import { useLogout } from "@/hooks/logout"; import { useConnection } from "@/hooks/useConnection"; -import { useMyOrg } from "@/hooks/useMyOrg"; import { OptionValue } from "@/types/common"; import NavbarItem from "./NavbarItem"; @@ -25,7 +25,7 @@ const NavbarContent = ({ handleClose, ...rest }: NavbarContentProps) => { const [, { isLoggedIn }] = useConnection(loginConnection); const router = useRouter(); const t = useT(); - const myOrg = useMyOrg(); + const [, myOrg] = useConnection(myOrganisationConnection); const logout = useLogout(); const { private: privateNavItems, public: publicNavItems } = getNavbarItems(t, myOrg); diff --git a/src/components/generic/Navbar/navbarItems.ts b/src/components/generic/Navbar/navbarItems.ts index 406ddc8e2..968f24f5f 100644 --- a/src/components/generic/Navbar/navbarItems.ts +++ b/src/components/generic/Navbar/navbarItems.ts @@ -1,8 +1,8 @@ import { useT } from "@transifex/react"; import { tourSelectors } from "@/components/extensive/WelcomeTour/useGetHomeTourItems"; +import { MyOrganisationConnection } from "@/connections/Organisation"; import { zendeskSupportLink } from "@/constants/links"; -import { V2MonitoringOrganisationRead } from "@/generated/apiSchemas"; interface INavbarItem { title: string; @@ -15,10 +15,10 @@ interface INavbarItems { private: INavbarItem[]; } -export const getNavbarItems = (t: typeof useT, myOrg?: V2MonitoringOrganisationRead | null): INavbarItems => { - const visibility = Boolean( - myOrg && myOrg?.status !== "rejected" && myOrg?.status !== "draft" && myOrg.users_status !== "requested" - ); +export const getNavbarItems = (t: typeof useT, myOrg?: MyOrganisationConnection): INavbarItems => { + const { userStatus, organisation } = myOrg ?? {}; + const { status } = organisation ?? {}; + const visibility = Boolean(organisation && status !== "rejected" && status !== "draft" && userStatus !== "requested"); return { public: [ @@ -53,7 +53,7 @@ export const getNavbarItems = (t: typeof useT, myOrg?: V2MonitoringOrganisationR }, { title: t("My Organization"), - url: myOrg?.uuid ? `/organization/${myOrg?.uuid}` : "/", + url: myOrg?.organisationId ? `/organization/${myOrg?.organisationId}` : "/", visibility, tourTarget: tourSelectors.ORGANIZATION }, diff --git a/src/connections/Organisation.ts b/src/connections/Organisation.ts index 2eb0db3c8..9ace0032c 100644 --- a/src/connections/Organisation.ts +++ b/src/connections/Organisation.ts @@ -1,5 +1,6 @@ import { createSelector } from "reselect"; +import { selectMe } from "@/connections/User"; import { OrganisationDto } from "@/generated/v3/userService/userServiceSchemas"; import { ApiDataStore } from "@/store/apiSlice"; import { Connection } from "@/types/connection"; @@ -7,24 +8,27 @@ import { selectorCache } from "@/utils/selectorCache"; type OrganisationConnection = { organisation?: OrganisationDto; +}; - /** - * Only included when this connection gets chained with a UserConnection so the meta on the - * relationship is available. - */ - userStatus?: "approved" | "rejected" | "requested"; +type UserStatus = "approved" | "rejected" | "requested"; +export type MyOrganisationConnection = OrganisationConnection & { + organisationId?: string; + userStatus?: UserStatus; }; type OrganisationConnectionProps = { organisationId?: string; }; +const selectOrganisations = (store: ApiDataStore) => store.organisations; const organisationSelector = (organisationId?: string) => (store: ApiDataStore) => organisationId == null ? undefined : store.organisations?.[organisationId]; +// TODO: This doesn't get a load/isLoaded until we have a v3 organisation get endpoint. For now we +// have to rely on the data that is already in the store. We might not even end up needing this +// connection, but it does illustrate nicely how to create a connection that takes props, so I'm +// leaving it in for now. export const organisationConnection: Connection = { - // TODO: This doesn't get a load/isLoaded until we have a v3 organisation get endpoint. For now - // we have to rely on the data that gets included in the users/me response. selector: selectorCache( ({ organisationId }) => organisationId ?? "", ({ organisationId }) => @@ -33,3 +37,20 @@ export const organisationConnection: Connection = { + selector: createSelector([selectMe, selectOrganisations], (user, orgs) => { + const { id, meta } = user?.relationships?.org?.[0] ?? {}; + if (id == null) return {}; + + return { + organisationId: id, + organisation: orgs?.[id]?.attributes, + userStatus: meta?.userStatus as UserStatus + }; + }) +}; diff --git a/src/connections/User.ts b/src/connections/User.ts index a9479d591..a3decc7ab 100644 --- a/src/connections/User.ts +++ b/src/connections/User.ts @@ -14,7 +14,9 @@ type UserConnection = { const selectMeId = (store: ApiDataStore) => store.meta.meUserId; const selectUsers = (store: ApiDataStore) => store.users; -const selectMe = createSelector([selectMeId, selectUsers], (meId, users) => (meId == null ? undefined : users?.[meId])); +export const selectMe = createSelector([selectMeId, selectUsers], (meId, users) => + meId == null ? undefined : users?.[meId] +); const FIND_ME: UsersFindVariables = { pathParams: { id: "me" } }; diff --git a/src/constants/options/userFrameworksChoices.ts b/src/constants/options/userFrameworksChoices.ts index 35a194e4a..a3b4fd9c4 100644 --- a/src/constants/options/userFrameworksChoices.ts +++ b/src/constants/options/userFrameworksChoices.ts @@ -1,14 +1,15 @@ import { useMemo } from "react"; -import { useUserData } from "@/hooks/useUserData"; +import { myUserConnection } from "@/connections/User"; +import { useConnection } from "@/hooks/useConnection"; import { OptionInputType } from "@/types/common"; export const useUserFrameworkChoices = (): OptionInputType[] => { - const userData = useUserData(); + const [, { user }] = useConnection(myUserConnection); - const frameworkChoices = useMemo(() => { + return useMemo(() => { return ( - userData?.frameworks?.map( + user?.frameworks?.map( f => ({ name: f.name, @@ -16,7 +17,5 @@ export const useUserFrameworkChoices = (): OptionInputType[] => { } as OptionInputType) ) ?? [] ); - }, [userData]); - - return frameworkChoices; + }, [user]); }; diff --git a/src/generated/v3/userService/userServiceSchemas.ts b/src/generated/v3/userService/userServiceSchemas.ts index 59c257689..bd957da65 100644 --- a/src/generated/v3/userService/userServiceSchemas.ts +++ b/src/generated/v3/userService/userServiceSchemas.ts @@ -29,6 +29,7 @@ export type UserFramework = { }; export type UserDto = { + uuid: string; firstName: string; lastName: string; /** diff --git a/src/generated/v3/utils.ts b/src/generated/v3/utils.ts index 4d8450871..fc9fa5d15 100644 --- a/src/generated/v3/utils.ts +++ b/src/generated/v3/utils.ts @@ -1,6 +1,7 @@ import ApiSlice, { ApiDataStore, isErrorState, isInProgress, Method, PendingErrorState } from "@/store/apiSlice"; import Log from "@/utils/log"; -import { getAccessToken } from "@/admin/apiProvider/utils/token"; +import { loginConnection } from "@/connections/Login"; +import { Connection, OptionalProps } from "@/types/connection"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; @@ -55,12 +56,19 @@ export function fetchFailed({ const isPending = (method: Method, fullUrl: string) => ApiSlice.apiDataStore.meta.pending[method][fullUrl] != null; +// We might want this utility more generally available. I'm hoping to avoid the need more widely, but I'm not totally +// opposed to this living in utils/ if we end up having a legitimate need for it. +const selectConnection = ( + connection: Connection, + props: P | Record = {} +) => connection.selector(ApiSlice.apiDataStore, props); + async function dispatchRequest(url: string, requestInit: RequestInit) { const actionPayload = { url, method: requestInit.method as Method }; ApiSlice.fetchStarting(actionPayload); try { - const response = await window.fetch(url, requestInit); + const response = await fetch(url, requestInit); if (!response.ok) { const error = (await response.json()) as ErrorWrapper; @@ -125,10 +133,15 @@ export function serviceFetch< ...headers }; - const accessToken = typeof window === "undefined" ? null : getAccessToken(); - if (!requestHeaders?.Authorization && accessToken != null) { + // Note: there's a race condition that I haven't figured out yet: the middleware in apiSlice that + // sets the access token in localStorage is firing _after_ the action has been merged into the + // store, which means that the next connections that kick off right away don't have access to + // the token through the getAccessToken method. So, we grab it from the store instead, which is + // more reliable in this case. + const { token } = selectConnection(loginConnection); + if (!requestHeaders?.Authorization && token != null) { // Always include the JWT access token if we have one. - requestHeaders.Authorization = `Bearer ${accessToken}`; + requestHeaders.Authorization = `Bearer ${token}`; } /** diff --git a/src/hooks/useConnections.ts b/src/hooks/useConnections.ts index 9a3c67dad..a3a5fe8e4 100644 --- a/src/hooks/useConnections.ts +++ b/src/hooks/useConnections.ts @@ -16,7 +16,10 @@ export function useConnections< P1 extends OptionalProps, P2 extends OptionalProps, P3 extends OptionalProps ->(connections: [Connection, Connection], props?: P1 & P2 & P3): readonly [boolean, [S1, S2, S3]]; +>( + connections: [Connection, Connection, Connection], + props?: P1 & P2 & P3 +): readonly [boolean, [S1, S2, S3]]; export function useConnections< S1, S2, diff --git a/src/hooks/useMyOrg.ts b/src/hooks/useMyOrg.ts deleted file mode 100644 index 0f45c8643..000000000 --- a/src/hooks/useMyOrg.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { UserRead, V2MonitoringOrganisationRead } from "@/generated/apiSchemas"; -import { useUserData } from "@/hooks/useUserData"; - -/** - * to get current user organisation - * @returns V2MonitoringOrganisationRead user organisation - */ -export const useMyOrg = () => { - const userData = useUserData(); - - if (userData) { - return getMyOrg(userData); - } else { - return null; - } -}; - -export const getMyOrg = (userData: UserRead): V2MonitoringOrganisationRead | undefined => { - //@ts-ignore - return userData?.organisation; -}; diff --git a/src/hooks/useUserData.ts b/src/hooks/useUserData.ts deleted file mode 100644 index 7851fe6bb..000000000 --- a/src/hooks/useUserData.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { loginConnection } from "@/connections/Login"; -import { organisationConnection } from "@/connections/Organisation"; -import { myUserConnection } from "@/connections/User"; -import { useGetAuthMe } from "@/generated/apiComponents"; -import { MeResponse } from "@/generated/apiSchemas"; -import { useConnection } from "@/hooks/useConnection"; -import { useConnections } from "@/hooks/useConnections"; -import Log from "@/utils/log"; - -/** - * To easily access user data - * @returns MeResponse - * - * TODO This hooks will be replaced in TM-1312, and the user data will be cached instead of re-fetched - * every 5 minutes for every component that uses this hook. - */ -export const useUserData = () => { - const [loaded, [{ token }, myUserResult]] = useConnections([loginConnection, myUserConnection]); - const organisationId = myUserResult?.userRelationships?.org?.[0]?.id; - const [, organisationResult] = useConnection(organisationConnection, { organisationId }); - Log.debug("myUserConnection", loaded, myUserResult, organisationResult); - const { data: authMe } = useGetAuthMe<{ data: MeResponse }>( - {}, - { - enabled: !!token, - staleTime: 300_000 //Data considered fresh for 5 min to prevent excess api call - } - ); - - return authMe?.data || null; -}; diff --git a/src/middleware.page.ts b/src/middleware.page.ts index c1c0e5c13..9adb154e7 100644 --- a/src/middleware.page.ts +++ b/src/middleware.page.ts @@ -2,9 +2,10 @@ import * as Sentry from "@sentry/nextjs"; import { NextRequest } from "next/server"; import { isAdmin, UserRole } from "@/admin/apiProvider/utils/user"; -import { fetchGetAuthMe } from "@/generated/apiComponents"; -import { UserRead } from "@/generated/apiSchemas"; -import { getMyOrg } from "@/hooks/useMyOrg"; +import { myOrganisationConnection } from "@/connections/Organisation"; +import { myUserConnection } from "@/connections/User"; +import { makeStore } from "@/store/store"; +import { loadConnection } from "@/utils/loadConnection"; import { MiddlewareCacheKey, MiddlewareMatcher } from "@/utils/MiddlewareMatcher"; //Todo: refactor this logic somewhere down the line as there are lot's of if/else nested! @@ -37,49 +38,47 @@ export async function middleware(request: NextRequest) { matcher.redirect("/auth/login"); }, async () => { - //Logged-in - const response = (await fetchGetAuthMe({ - headers: { Authorization: `Bearer ${accessToken}` } - })) as { data: UserRead }; + // Set up the redux store. + makeStore(accessToken); - const userData = response.data; + const { user } = await loadConnection(myUserConnection); + const { organisationId, organisation, userStatus } = await loadConnection(myOrganisationConnection); matcher.if( - !userData?.email_address_verified_at, + !user?.emailAddressVerifiedAt, () => { //Email is not verified - matcher.redirect(`/auth/signup/confirm?email=${userData.email_address}`); + matcher.redirect(`/auth/signup/confirm?email=${user?.emailAddress}`); }, () => { //Email is verified - //@ts-ignore - const myOrg = userData && getMyOrg(userData); - const userIsAdmin = isAdmin(userData?.role as UserRole); + const userIsAdmin = isAdmin(user?.primaryRole as UserRole); - matcher.when(!!userData && userIsAdmin)?.redirect(`/admin`, { cacheResponse: true }); + matcher.when(user != null && userIsAdmin)?.redirect(`/admin`, { cacheResponse: true }); matcher - .when(!!myOrg && !!myOrg?.status && myOrg?.status !== "draft") + .when(organisation != null && organisation.status !== "draft") ?.startWith("/organization/create") ?.redirect(`/organization/create/confirm`); - matcher.when(!myOrg)?.redirect(`/organization/assign`); + matcher.when(organisation == null)?.redirect(`/organization/assign`); - matcher.when(!!myOrg && (!myOrg?.status || myOrg?.status === "draft"))?.redirect(`/organization/create`); + matcher.when(organisation?.status === "draft")?.redirect(`/organization/create`); - matcher - .when(!!myOrg && !!myOrg?.users_status && myOrg?.users_status === "requested") - ?.redirect(`/organization/status/pending`); + matcher.when(userStatus === "requested")?.redirect(`/organization/status/pending`); - matcher.when(!!myOrg)?.exact("/organization")?.redirect(`/organization/${myOrg?.uuid}`); + matcher + .when(organisationId != null) + ?.exact("/organization") + ?.redirect(`/organization/${organisationId}`); - matcher.when(!!myOrg && myOrg?.status === "rejected")?.redirect(`/organization/status/rejected`); + matcher.when(organisation?.status === "rejected")?.redirect(`/organization/status/rejected`); matcher.exact("/")?.redirect(`/home`); matcher.startWith("/auth")?.redirect("/home"); - if (!userIsAdmin && !!myOrg && myOrg.status === "approved" && myOrg?.users_status !== "requested") { + if (!userIsAdmin && organisation?.status === "approved" && userStatus !== "requested") { //Cache result if user has and approved org matcher.next().cache("/home"); } else { diff --git a/src/pages/form/[id]/pitch-select.page.tsx b/src/pages/form/[id]/pitch-select.page.tsx index ceeaf2972..fdf010633 100644 --- a/src/pages/form/[id]/pitch-select.page.tsx +++ b/src/pages/form/[id]/pitch-select.page.tsx @@ -13,10 +13,11 @@ import Form from "@/components/extensive/Form/Form"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; +import { myOrganisationConnection } from "@/connections/Organisation"; import { useGetV2FormsUUID, useGetV2ProjectPitches, usePostV2FormsSubmissions } from "@/generated/apiComponents"; import { FormRead } from "@/generated/apiSchemas"; +import { useConnection } from "@/hooks/useConnection"; import { useDate } from "@/hooks/useDate"; -import { useMyOrg } from "@/hooks/useMyOrg"; const schema = yup.object({ pitch_uuid: yup.string().required(), @@ -28,11 +29,10 @@ export type FormData = yup.InferType; const FormIntroPage = () => { const t = useT(); const router = useRouter(); - const myOrg = useMyOrg(); const { format } = useDate(); + const [, { organisationId }] = useConnection(myOrganisationConnection); const formUUID = router.query.id as string; - const orgUUID = myOrg?.uuid as string; const form = useForm({ resolver: yupResolver(schema) @@ -51,11 +51,11 @@ const FormIntroPage = () => { page: 1, per_page: 10000, //@ts-ignore - "filter[organisation_id]": orgUUID + "filter[organisation_id]": organisationId } }, { - enabled: !!orgUUID + enabled: !!organisationId } ); diff --git a/src/pages/home.page.tsx b/src/pages/home.page.tsx index 73df2e65c..20f4b962b 100644 --- a/src/pages/home.page.tsx +++ b/src/pages/home.page.tsx @@ -14,14 +14,15 @@ import TaskList from "@/components/extensive/TaskList/TaskList"; import { useGetHomeTourItems } from "@/components/extensive/WelcomeTour/useGetHomeTourItems"; import WelcomeTour from "@/components/extensive/WelcomeTour/WelcomeTour"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; +import { myOrganisationConnection } from "@/connections/Organisation"; import { useGetV2FundingProgramme } from "@/generated/apiComponents"; +import { useConnection } from "@/hooks/useConnection"; import { useAcceptInvitation } from "@/hooks/useInviteToken"; -import { useMyOrg } from "@/hooks/useMyOrg"; import { fundingProgrammeToFundingCardProps } from "@/utils/dataTransformation"; const HomePage = () => { const t = useT(); - const myOrg = useMyOrg(); + const [, { organisation, organisationId }] = useConnection(myOrganisationConnection); const route = useRouter(); const tourSteps = useGetHomeTourItems(); useAcceptInvitation(); @@ -47,7 +48,7 @@ const HomePage = () => { - + { } /> - - - + - + Funding Opportunities`)} @@ -75,7 +74,7 @@ const HomePage = () => { title: t("Organizational Information"), subtitle: t("Keep your profile updated to have more chances of having a successful application. "), actionText: t("View"), - actionUrl: `/organization/${myOrg?.uuid}`, + actionUrl: `/organization/${organisationId}`, iconProps: { name: IconNames.BRANCH_CIRCLE, className: "fill-success" @@ -87,7 +86,7 @@ const HomePage = () => { 'Start a pitch or edit your pitches to apply for funding opportunities. To go to create a pitch, manage your pitches/funding applications, tap on "view".' ), actionText: t("View"), - actionUrl: `/organization/${myOrg?.uuid}?tab=pitches`, + actionUrl: `/organization/${organisationId}?tab=pitches`, iconProps: { name: IconNames.LIGHT_BULB_CIRCLE, className: "fill-success" diff --git a/src/pages/my-projects/index.page.tsx b/src/pages/my-projects/index.page.tsx index 1dc514c19..82973b396 100644 --- a/src/pages/my-projects/index.page.tsx +++ b/src/pages/my-projects/index.page.tsx @@ -15,13 +15,14 @@ import PageFooter from "@/components/extensive/PageElements/Footer/PageFooter"; import PageHeader from "@/components/extensive/PageElements/Header/PageHeader"; import PageSection from "@/components/extensive/PageElements/Section/PageSection"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; +import { myOrganisationConnection } from "@/connections/Organisation"; import { ToastType, useToastContext } from "@/context/toast.provider"; import { GetV2MyProjectsResponse, useDeleteV2ProjectsUUID, useGetV2MyProjects } from "@/generated/apiComponents"; -import { useMyOrg } from "@/hooks/useMyOrg"; +import { useConnection } from "@/hooks/useConnection"; const MyProjectsPage = () => { const t = useT(); - const myOrg = useMyOrg(); + const [, { organisation }] = useConnection(myOrganisationConnection); const { openToast } = useToastContext(); const { data: projectsData, isLoading, refetch } = useGetV2MyProjects<{ data: GetV2MyProjectsResponse }>({}); @@ -51,7 +52,7 @@ const MyProjectsPage = () => { - + 0}> diff --git a/src/pages/opportunities/index.page.tsx b/src/pages/opportunities/index.page.tsx index 9b80013fb..658d127ae 100644 --- a/src/pages/opportunities/index.page.tsx +++ b/src/pages/opportunities/index.page.tsx @@ -18,14 +18,15 @@ import PageSection from "@/components/extensive/PageElements/Section/PageSection import ApplicationsTable from "@/components/extensive/Tables/ApplicationsTable"; import PitchesTable from "@/components/extensive/Tables/PitchesTable"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; +import { myOrganisationConnection } from "@/connections/Organisation"; import { useGetV2FundingProgramme, useGetV2MyApplications } from "@/generated/apiComponents"; -import { useMyOrg } from "@/hooks/useMyOrg"; +import { useConnection } from "@/hooks/useConnection"; import { fundingProgrammeToFundingCardProps } from "@/utils/dataTransformation"; const OpportunitiesPage = () => { const t = useT(); const route = useRouter(); - const myOrg = useMyOrg(); + const [, { organisation, organisationId }] = useConnection(myOrganisationConnection); const [pitchesCount, setPitchesCount] = useState(); const { data: fundingProgrammes, isLoading: loadingFundingProgrammes } = useGetV2FundingProgramme({ @@ -50,7 +51,7 @@ const OpportunitiesPage = () => { - + @@ -104,13 +105,15 @@ const OpportunitiesPage = () => { "You can use pitches to apply for funding opportunities. By creating a pitch, you will have a ready-to-use resource that can be used to submit applications when funding opportunities are announced." )} headerChildren={ - } > - {/* @ts-ignore missing total field in docs */} - setPitchesCount(data.meta?.total)} /> + setPitchesCount((data.meta as any)?.total)} + /> @@ -126,7 +129,7 @@ const OpportunitiesPage = () => { iconProps={{ name: IconNames.LIGHT_BULB_CIRCLE, className: "fill-success" }} ctaProps={{ as: Link, - href: `/organization/${myOrg?.uuid}/project-pitch/create/intro`, + href: `/organization/${organisationId}/project-pitch/create/intro`, children: t("Create Pitch") }} /> diff --git a/src/pages/organization/create/index.page.tsx b/src/pages/organization/create/index.page.tsx index 337c37554..87010aab5 100644 --- a/src/pages/organization/create/index.page.tsx +++ b/src/pages/organization/create/index.page.tsx @@ -7,6 +7,7 @@ import { ModalId } from "@/components/extensive/Modal/ModalConst"; import WizardForm from "@/components/extensive/WizardForm"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; +import { myOrganisationConnection } from "@/connections/Organisation"; import { useModalContext } from "@/context/modal.provider"; import { useDeleteV2OrganisationsRetractMyDraft, @@ -15,19 +16,19 @@ import { usePutV2OrganisationsUUID } from "@/generated/apiComponents"; import { V2OrganisationRead } from "@/generated/apiSchemas"; +import { useConnection } from "@/hooks/useConnection"; import { useNormalizedFormDefaultValue } from "@/hooks/useGetCustomFormSteps/useGetCustomFormSteps"; -import { useMyOrg } from "@/hooks/useMyOrg"; import { getSteps } from "./getCreateOrganisationSteps"; const CreateOrganisationForm = () => { const t = useT(); const router = useRouter(); - const myOrg = useMyOrg(); + const [, { organisationId }] = useConnection(myOrganisationConnection); const { openModal, closeModal } = useModalContext(); const queryClient = useQueryClient(); - const uuid = (myOrg?.uuid || router?.query?.uuid) as string; + const uuid = (organisationId || router?.query?.uuid) as string; const { mutate: updateOrganisation, isLoading, isSuccess } = usePutV2OrganisationsUUID({}); diff --git a/src/pages/organization/status/pending.page.tsx b/src/pages/organization/status/pending.page.tsx index ebe6cc5be..cc0b04aa4 100644 --- a/src/pages/organization/status/pending.page.tsx +++ b/src/pages/organization/status/pending.page.tsx @@ -5,11 +5,12 @@ import HandsPlantingImage from "public/images/hands-planting.webp"; import Text from "@/components/elements/Text/Text"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; -import { useMyOrg } from "@/hooks/useMyOrg"; +import { myOrganisationConnection } from "@/connections/Organisation"; +import { useConnection } from "@/hooks/useConnection"; const OrganizationPendingPage = () => { const t = useT(); - const myOrg = useMyOrg(); + const [, { organisation }] = useConnection(myOrganisationConnection); return ( @@ -23,7 +24,7 @@ const OrganizationPendingPage = () => { {t( "You'll receive an email confirmation when your request has been approved. Ask a member of your organization ({organizationName}) to approve your request.", - { organizationName: myOrg?.name } + { organizationName: organisation?.name } )}
diff --git a/src/pages/organization/status/rejected.page.tsx b/src/pages/organization/status/rejected.page.tsx index e992b80e5..2ced19e31 100644 --- a/src/pages/organization/status/rejected.page.tsx +++ b/src/pages/organization/status/rejected.page.tsx @@ -6,11 +6,12 @@ import Text from "@/components/elements/Text/Text"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; +import { myOrganisationConnection } from "@/connections/Organisation"; import { zendeskSupportLink } from "@/constants/links"; -import { useMyOrg } from "@/hooks/useMyOrg"; +import { useConnection } from "@/hooks/useConnection"; const OrganizationRejectedPage = () => { - const myOrg = useMyOrg(); + const [, { organisation }] = useConnection(myOrganisationConnection); const t = useT(); return ( @@ -25,7 +26,7 @@ const OrganizationRejectedPage = () => { {t( "Your request to create/join the organization ({ organizationName }) has been rejected. You have been locked out of the platform and your account has been rejected.", - { organizationName: myOrg?.name } + { organizationName: organisation?.name } )}
diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index aeca13e63..306a0553d 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -4,6 +4,7 @@ import { isArray } from "lodash"; import { Store } from "redux"; import { setAccessToken } from "@/admin/apiProvider/utils/token"; +import { usersFind } from "@/generated/v3/userService/userServiceComponents"; import { LoginDto, OrganisationDto, UserDto } from "@/generated/v3/userService/userServiceSchemas"; export type PendingErrorState = { @@ -115,11 +116,15 @@ const clearApiCache = (state: WritableDraft) => { for (const method of METHODS) { state.meta.pending[method] = {}; } + + reloadMe(); }; const isLogin = ({ url, method }: { url: string; method: Method }) => url.endsWith("auth/v3/logins") && method === "POST"; +const reloadMe = () => setTimeout(() => usersFind({ pathParams: { id: "me" } }), 0); + export const apiSlice = createSlice({ name: "api", initialState, @@ -179,6 +184,7 @@ export const apiSlice = createSlice({ // so we can safely fake a login into the store when we have an authToken already set in a // cookie on app bootup. state.logins["1"] = { attributes: { token: authToken } }; + reloadMe(); } } }); diff --git a/src/store/store.ts b/src/store/store.ts index 9d38ef319..dbdd1a53b 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,6 +1,6 @@ import { configureStore } from "@reduxjs/toolkit"; import { Store } from "redux"; -import { logger } from "redux-logger"; +import { createLogger } from "redux-logger"; import ApiSlice, { ApiDataStore, apiSlice, authListenerMiddleware } from "@/store/apiSlice"; @@ -8,25 +8,41 @@ export type AppStore = { api: ApiDataStore; }; +let store: Store; + export const makeStore = (authToken?: string): Store => { - const store = configureStore({ + if (store != null) return store; + + store = configureStore({ reducer: { api: apiSlice.reducer }, middleware: getDefaultMiddleware => { - if (process.env.NODE_ENV === "production" || process.env.NODE_ENV === "test") { + if ( + process.env.NEXT_RUNTIME === "nodejs" || + process.env.NODE_ENV === "production" || + process.env.NODE_ENV === "test" + ) { return getDefaultMiddleware().prepend(authListenerMiddleware.middleware); } else { + // Most of our actions include a URL, and it's useful to have that in the top level visible + // log when it's present. + const logger = createLogger({ + titleFormatter: (action: any, time: string, took: number) => { + const extra = action?.payload?.url == null ? "" : ` [${action.payload.url}]`; + return `action @ ${time} ${action.type} (in ${took.toFixed(2)} ms)${extra}`; + } + }); return getDefaultMiddleware().prepend(authListenerMiddleware.middleware).concat(logger); } } }); + ApiSlice.redux = store; + if (authToken != null) { store.dispatch(apiSlice.actions.setInitialAuthToken({ authToken })); } - ApiSlice.redux = store; - return store; }; diff --git a/src/utils/loadConnection.ts b/src/utils/loadConnection.ts new file mode 100644 index 000000000..f4912829e --- /dev/null +++ b/src/utils/loadConnection.ts @@ -0,0 +1,30 @@ +import { Unsubscribe } from "redux"; + +import ApiSlice, { ApiDataStore } from "@/store/apiSlice"; +import { Connection, OptionalProps } from "@/types/connection"; + +export async function loadConnection( + connection: Connection, + props: PType | Record = {} +) { + const { selector, isLoaded, load } = connection; + const predicate = (store: ApiDataStore) => { + const connected = selector(store, props); + const loaded = isLoaded == null || isLoaded(connected, props); + // Delay to avoid calling dispatch during store update resolution + if (!loaded && load != null) setTimeout(() => load(connected, props), 0); + return loaded; + }; + + const store = ApiSlice.apiDataStore; + if (predicate(store)) return selector(store, props); + + const unsubscribe = await new Promise(resolve => { + const unsubscribe = ApiSlice.redux.subscribe(() => { + if (predicate(ApiSlice.apiDataStore)) resolve(unsubscribe); + }); + }); + unsubscribe(); + + return selector(ApiSlice.apiDataStore, props); +} From 1771e2f9299298160709f31abcef3f555be58c65 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 30 Sep 2024 14:40:38 -0700 Subject: [PATCH 061/102] [TM-1312] Get the redux store to play nice with SSR. (cherry picked from commit 6513db67a2e52277e4818ec79d035360a5a14adc) --- package.json | 1 + src/hooks/logout.ts | 4 ---- src/middleware.page.ts | 28 +++++++++++++++++--------- src/pages/_app.tsx | 40 ++++++++++++++++++++++++------------- src/store/StoreProvider.tsx | 22 -------------------- src/store/apiSlice.ts | 26 +++++++++++++++++++++++- src/store/store.ts | 28 ++++++++++---------------- yarn.lock | 5 +++++ 8 files changed, 87 insertions(+), 67 deletions(-) delete mode 100644 src/store/StoreProvider.tsx diff --git a/package.json b/package.json index c982b22b7..a61cc42ed 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "mapbox-gl": "^2.15.0", "mapbox-gl-draw-circle": "^1.1.2", "next": "13.1.5", + "next-redux-wrapper": "^8.1.0", "nookies": "^2.5.2", "prettier-plugin-tailwindcss": "^0.2.2", "ra-input-rich-text": "^4.12.2", diff --git a/src/hooks/logout.ts b/src/hooks/logout.ts index b2938d82a..a9302d633 100644 --- a/src/hooks/logout.ts +++ b/src/hooks/logout.ts @@ -1,17 +1,13 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useRouter } from "next/router"; import { logout } from "@/connections/Login"; export const useLogout = () => { const queryClient = useQueryClient(); - const router = useRouter(); return () => { queryClient.getQueryCache().clear(); queryClient.clear(); logout(); - router.push("/"); - window.location.replace("/"); }; }; diff --git a/src/middleware.page.ts b/src/middleware.page.ts index 9adb154e7..492551c24 100644 --- a/src/middleware.page.ts +++ b/src/middleware.page.ts @@ -2,10 +2,8 @@ import * as Sentry from "@sentry/nextjs"; import { NextRequest } from "next/server"; import { isAdmin, UserRole } from "@/admin/apiProvider/utils/user"; -import { myOrganisationConnection } from "@/connections/Organisation"; -import { myUserConnection } from "@/connections/User"; -import { makeStore } from "@/store/store"; -import { loadConnection } from "@/utils/loadConnection"; +import { UserDto } from "@/generated/v3/userService/userServiceSchemas"; +import { resolveUrl } from "@/generated/v3/utils"; import { MiddlewareCacheKey, MiddlewareMatcher } from "@/utils/MiddlewareMatcher"; //Todo: refactor this logic somewhere down the line as there are lot's of if/else nested! @@ -38,11 +36,23 @@ export async function middleware(request: NextRequest) { matcher.redirect("/auth/login"); }, async () => { - // Set up the redux store. - makeStore(accessToken); - - const { user } = await loadConnection(myUserConnection); - const { organisationId, organisation, userStatus } = await loadConnection(myOrganisationConnection); + // The redux store isn't available yet at this point, so we do a quick manual users/me fetch + // to get the data we need to resolve routing. + const result = await fetch(resolveUrl("/users/v3/users/me"), { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}` + } + }); + const json = await result.json(); + + const user = json.data.attributes as UserDto; + const { + id: organisationId, + meta: { userStatus } + } = json.data.relationships.org.data; + const organisation = json.included[0]; matcher.if( !user?.emailAddressVerifiedAt, diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 125b70206..0dbb82d4d 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -9,11 +9,14 @@ import dynamic from "next/dynamic"; import { useRouter } from "next/router"; import nookies from "nookies"; import { Else, If, Then } from "react-if"; +import { Provider as ReduxProvider } from "react-redux"; import Toast from "@/components/elements/Toast/Toast"; import ModalRoot from "@/components/extensive/Modal/ModalRoot"; import DashboardLayout from "@/components/generic/Layout/DashboardLayout"; import MainLayout from "@/components/generic/Layout/MainLayout"; +import { loginConnection } from "@/connections/Login"; +import { myUserConnection } from "@/connections/User"; import { LoadingProvider } from "@/context/loaderAdmin.provider"; import ModalProvider from "@/context/modal.provider"; import NavbarProvider from "@/context/navbar.provider"; @@ -22,7 +25,9 @@ import WrappedQueryClientProvider from "@/context/queryclient.provider"; import RouteHistoryProvider from "@/context/routeHistory.provider"; import ToastProvider from "@/context/toast.provider"; import { getServerSideTranslations, setClientSideTranslations } from "@/i18n"; -import StoreProvider from "@/store/StoreProvider"; +import { apiSlice } from "@/store/apiSlice"; +import { wrapper } from "@/store/store"; +import { loadConnection } from "@/utils/loadConnection"; import Log from "@/utils/log"; import setupYup from "@/yup.locale"; @@ -30,36 +35,38 @@ const CookieBanner = dynamic(() => import("@/components/extensive/CookieBanner/C ssr: false }); -const _App = ({ Component, pageProps, props, authToken }: AppProps & { authToken?: string; props: any }) => { +const _App = ({ Component, ...rest }: AppProps) => { const t = useT(); const router = useRouter(); const isAdmin = router.asPath.includes("/admin"); const isOnDashboards = router.asPath.includes("/dashboard"); + const { store, props } = wrapper.useWrappedStore(rest); + setClientSideTranslations(props); setupYup(t); if (isAdmin) return ( - + - + - + ); else return ( - + - + @@ -70,12 +77,12 @@ const _App = ({ Component, pageProps, props, authToken }: AppProps & { authToken - + - + @@ -89,20 +96,25 @@ const _App = ({ Component, pageProps, props, authToken }: AppProps & { authToken - + ); }; -_App.getInitialProps = async (context: AppContext) => { +_App.getInitialProps = wrapper.getInitialAppProps(store => async (context: AppContext) => { + const authToken = nookies.get(context.ctx).accessToken; + if (authToken != null && (await loadConnection(loginConnection)).token !== authToken) { + store.dispatch(apiSlice.actions.setInitialAuthToken({ authToken })); + await loadConnection(myUserConnection); + } + const ctx = await App.getInitialProps(context); - const cookies = nookies.get(context.ctx); let translationsData = {}; try { translationsData = await getServerSideTranslations(context.ctx); } catch (err) { Log.warn("Failed to get Serverside Transifex", err); } - return { ...ctx, props: { ...translationsData }, authToken: cookies.accessToken }; -}; + return { ...ctx, props: { ...translationsData } }; +}); export default _App; diff --git a/src/store/StoreProvider.tsx b/src/store/StoreProvider.tsx deleted file mode 100644 index ad6f3e84d..000000000 --- a/src/store/StoreProvider.tsx +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; -import { useRef } from "react"; -import { Provider } from "react-redux"; -import { Store } from "redux"; - -import { AppStore, makeStore } from "./store"; - -export default function StoreProvider({ - authToken = undefined, - children -}: { - authToken?: string; - children: React.ReactNode; -}) { - const storeRef = useRef>(); - if (!storeRef.current) { - // Create the store instance the first time this renders - storeRef.current = makeStore(authToken); - } - - return {children}; -} diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index 306a0553d..1e39ed560 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -1,6 +1,7 @@ import { createListenerMiddleware, createSlice, PayloadAction } from "@reduxjs/toolkit"; import { WritableDraft } from "immer"; import { isArray } from "lodash"; +import { HYDRATE } from "next-redux-wrapper"; import { Store } from "redux"; import { setAccessToken } from "@/admin/apiProvider/utils/token"; @@ -117,7 +118,7 @@ const clearApiCache = (state: WritableDraft) => { state.meta.pending[method] = {}; } - reloadMe(); + delete state.meta.meUserId; }; const isLogin = ({ url, method }: { url: string; method: Method }) => @@ -127,7 +128,9 @@ const reloadMe = () => setTimeout(() => usersFind({ pathParams: { id: "me" } }), export const apiSlice = createSlice({ name: "api", + initialState, + reducers: { apiFetchStarting: (state, action: PayloadAction) => { const { url, method } = action.payload; @@ -143,6 +146,7 @@ export const apiSlice = createSlice({ // After a successful login, clear the entire cache; we want all mounted components to // re-fetch their data with the new login credentials. clearApiCache(state); + reloadMe(); } else { delete state.meta.pending[method][url]; } @@ -186,6 +190,26 @@ export const apiSlice = createSlice({ state.logins["1"] = { attributes: { token: authToken } }; reloadMe(); } + }, + + extraReducers: builder => { + builder.addCase(HYDRATE, (state, action) => { + clearApiCache(state); + + const { payload } = action as unknown as PayloadAction<{ api: ApiDataStore }>; + + for (const resource of RESOURCES) { + state[resource] = payload.api[resource] as any; + } + + for (const method of METHODS) { + state.meta.pending[method] = payload.api.meta.pending[method]; + } + + if (payload.api.meta.meUserId != null) { + state.meta.meUserId = payload.api.meta.meUserId; + } + }); } }); diff --git a/src/store/store.ts b/src/store/store.ts index dbdd1a53b..d82faa6c3 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,4 +1,5 @@ import { configureStore } from "@reduxjs/toolkit"; +import { createWrapper, MakeStore } from "next-redux-wrapper"; import { Store } from "redux"; import { createLogger } from "redux-logger"; @@ -8,23 +9,16 @@ export type AppStore = { api: ApiDataStore; }; -let store: Store; - -export const makeStore = (authToken?: string): Store => { - if (store != null) return store; - - store = configureStore({ +const makeStore: MakeStore> = context => { + const store = configureStore({ reducer: { api: apiSlice.reducer }, middleware: getDefaultMiddleware => { - if ( - process.env.NEXT_RUNTIME === "nodejs" || - process.env.NODE_ENV === "production" || - process.env.NODE_ENV === "test" - ) { - return getDefaultMiddleware().prepend(authListenerMiddleware.middleware); - } else { + const includeLogger = + typeof window !== "undefined" && process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test"; + + if (includeLogger) { // Most of our actions include a URL, and it's useful to have that in the top level visible // log when it's present. const logger = createLogger({ @@ -34,15 +28,15 @@ export const makeStore = (authToken?: string): Store => { } }); return getDefaultMiddleware().prepend(authListenerMiddleware.middleware).concat(logger); + } else { + return getDefaultMiddleware().prepend(authListenerMiddleware.middleware); } } }); ApiSlice.redux = store; - if (authToken != null) { - store.dispatch(apiSlice.actions.setInitialAuthToken({ authToken })); - } - return store; }; + +export const wrapper = createWrapper>(makeStore); diff --git a/yarn.lock b/yarn.lock index 6860e8436..d638fe11d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11739,6 +11739,11 @@ neo-async@^2.5.0, neo-async@^2.6.0, neo-async@^2.6.1, neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +next-redux-wrapper@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/next-redux-wrapper/-/next-redux-wrapper-8.1.0.tgz#d9c135f1ceeb2478375bdacd356eb9db273d3a07" + integrity sha512-2hIau0hcI6uQszOtrvAFqgc0NkZegKYhBB7ZAKiG3jk7zfuQb4E7OV9jfxViqqojh3SEHdnFfPkN9KErttUKuw== + next-router-mock@^0.9.3: version "0.9.3" resolved "https://registry.yarnpkg.com/next-router-mock/-/next-router-mock-0.9.3.tgz#8287e96d76d4c7b3720bc9078b148c2b352f1567" From 177df14c072d52707b4db5e785fc6a86957ef249 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 30 Sep 2024 15:15:31 -0700 Subject: [PATCH 062/102] [TM-1312] Get the admin site working again. (cherry picked from commit 4e4f9c5d4175c1bcc4005f36a24d8d99769b85ed) --- src/admin/apiProvider/authProvider.ts | 61 +++++++++++---------------- src/admin/components/App.tsx | 26 +++--------- src/admin/hooks/useGetUserRole.ts | 8 ++-- src/store/apiSlice.ts | 3 +- src/store/store.ts | 2 +- 5 files changed, 37 insertions(+), 63 deletions(-) diff --git a/src/admin/apiProvider/authProvider.ts b/src/admin/apiProvider/authProvider.ts index d9625c8f2..6a2d66fbf 100644 --- a/src/admin/apiProvider/authProvider.ts +++ b/src/admin/apiProvider/authProvider.ts @@ -1,54 +1,41 @@ import { AuthProvider } from "react-admin"; -import { fetchGetAuthLogout } from "@/generated/apiComponents"; +import { isAdmin, UserRole } from "@/admin/apiProvider/utils/user"; +import { loginConnection, logout } from "@/connections/Login"; +import { myUserConnection } from "@/connections/User"; +import { loadConnection } from "@/utils/loadConnection"; import Log from "@/utils/log"; -import { getAccessToken, removeAccessToken } from "./utils/token"; - export const authProvider: AuthProvider = { - // send username and password to the auth server and get back credentials login: async () => { Log.error("Admin app does not support direct login"); }, - // when the dataProvider returns an error, check if this is an authentication error - checkError: () => { - return Promise.resolve(); - }, + checkError: async () => {}, // when the user navigates, make sure that their credentials are still valid checkAuth: async () => { - const token = getAccessToken(); - if (!token) return Promise.reject(); - - // TODO (TM-1312) Once we have a connection for the users/me object, we can check the cached - // value without re-fetching on every navigation in the admin UI. The previous implementation - // is included below for reference until that ticket is complete. - // return new Promise((resolve, reject) => { - // fetchGetAuthMe({}) - // .then(res => { - // //@ts-ignore - // if (isAdmin(res.data.role)) resolve(); - // else reject("Only admins are allowed."); - // }) - // .catch(() => reject()); - // }); + const { user } = await loadConnection(myUserConnection); + if (user == null) throw "No user logged in."; + + if (!isAdmin(user.primaryRole as UserRole)) throw "Only admins are allowed."; }, - // remove local credentials and notify the auth server that the user logged out + + // remove local credentials logout: async () => { - const token = getAccessToken(); - if (!token) return Promise.resolve(); - - return new Promise(resolve => { - fetchGetAuthLogout({}) - .then(async () => { - removeAccessToken(); - window.location.replace("/auth/login"); - }) - .catch(() => { - resolve(); - }); - }); + console.log("LOGOUT"); + const { isLoggedIn } = await loadConnection(loginConnection); + if (isLoggedIn) { + logout(); + window.location.replace("/auth/login"); + } + }, + + getIdentity: async () => { + const { user } = await loadConnection(myUserConnection); + if (user == null) throw "No user logged in."; + + return { id: user.uuid, fullName: user.fullName, primaryRole: user.primaryRole }; }, // get the user permissions (optional) diff --git a/src/admin/components/App.tsx b/src/admin/components/App.tsx index d073f8c98..e552a531c 100644 --- a/src/admin/components/App.tsx +++ b/src/admin/components/App.tsx @@ -1,6 +1,4 @@ import SummarizeIcon from "@mui/icons-material/Summarize"; -import router from "next/router"; -import { useEffect, useState } from "react"; import { Admin, Resource } from "react-admin"; import { authProvider } from "@/admin/apiProvider/authProvider"; @@ -8,31 +6,19 @@ import { dataProvider } from "@/admin/apiProvider/dataProviders"; import { AppLayout } from "@/admin/components/AppLayout"; import { theme } from "@/admin/components/theme"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; +import { myUserConnection } from "@/connections/User"; import { LoadingProvider } from "@/context/loaderAdmin.provider"; +import { useConnection } from "@/hooks/useConnection"; import LoginPage from "@/pages/auth/login/index.page"; import modules from "../modules"; const App = () => { - const [identity, setIdentity] = useState(null); + const [, { user }] = useConnection(myUserConnection); + if (user == null) return null; - useEffect(() => { - const getIdentity = async () => { - try { - const data: any = await authProvider?.getIdentity?.(); - setIdentity(data); - } catch (error) { - router.push("/auth/login"); - } - }; - - getIdentity(); - }, []); - - if (identity == null) return null; - - const canCreate = identity.role === "admin-super"; - const isAdmin = identity.role?.includes("admin"); + const canCreate = user.primaryRole === "admin-super"; + const isAdmin = user.primaryRole.includes("admin"); return ( { const user: any = data || {}; return { - role: user.role, - isSuperAdmin: user.role === "admin-super", - isPPCAdmin: user.role === "admin-ppc", - isPPCTerrafundAdmin: user.role === "admin-terrafund" + role: user.primaryRole, + isSuperAdmin: user.primaryRole === "admin-super", + isPPCAdmin: user.primaryRole === "admin-ppc", + isPPCTerrafundAdmin: user.primaryRole === "admin-terrafund" }; }; diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index 1e39ed560..af9fadc86 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -146,6 +146,8 @@ export const apiSlice = createSlice({ // After a successful login, clear the entire cache; we want all mounted components to // re-fetch their data with the new login credentials. clearApiCache(state); + // TODO: this will no longer be needed once we have connection chaining, as the my org + // connection will force the my user connection to load. reloadMe(); } else { delete state.meta.pending[method][url]; @@ -188,7 +190,6 @@ export const apiSlice = createSlice({ // so we can safely fake a login into the store when we have an authToken already set in a // cookie on app bootup. state.logins["1"] = { attributes: { token: authToken } }; - reloadMe(); } }, diff --git a/src/store/store.ts b/src/store/store.ts index d82faa6c3..818ee3bf5 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -9,7 +9,7 @@ export type AppStore = { api: ApiDataStore; }; -const makeStore: MakeStore> = context => { +const makeStore: MakeStore> = () => { const store = configureStore({ reducer: { api: apiSlice.reducer From 02507914dc120fd121f416677389ff116d2cb62b Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 30 Sep 2024 15:55:03 -0700 Subject: [PATCH 063/102] [TM-1312] Make the default pattern for accessing connections be through shorter accessor hooks / loaders. (cherry picked from commit d19610bac5c31781ec4a4e2d8530127c4cf839c6) --- src/admin/apiProvider/authProvider.ts | 12 ++- src/admin/components/App.tsx | 5 +- .../CommentarySection/CommentarySection.tsx | 5 +- .../DataTable/RHFCoreTeamLeadersTable.tsx | 5 +- .../DataTable/RHFFundingTypeDataTable.tsx | 5 +- .../DataTable/RHFLeadershipTeamTable.tsx | 5 +- .../DataTable/RHFOwnershipStakeTable.tsx | 5 +- .../extensive/Modal/ModalWithLogo.tsx | 5 +- .../extensive/WelcomeTour/WelcomeTour.tsx | 5 +- .../generic/Navbar/NavbarContent.tsx | 9 +-- src/connections/Login.ts | 6 +- src/connections/Organisation.ts | 6 +- src/connections/User.ts | 5 +- .../options/userFrameworksChoices.ts | 5 +- src/generated/apiContext.ts | 5 +- src/generated/v3/utils.ts | 12 +-- src/hooks/useConnection.ts | 2 +- src/hooks/useConnections.ts | 73 ------------------- src/pages/_app.tsx | 9 +-- src/pages/auth/login/index.page.tsx | 5 +- src/pages/form/[id]/pitch-select.page.tsx | 5 +- src/pages/home.page.tsx | 5 +- src/pages/my-projects/index.page.tsx | 5 +- src/pages/opportunities/index.page.tsx | 5 +- src/pages/organization/create/index.page.tsx | 5 +- .../organization/status/pending.page.tsx | 5 +- .../organization/status/rejected.page.tsx | 5 +- src/types/connection.ts | 2 +- src/utils/connectionShortcuts.ts | 30 ++++++++ 29 files changed, 96 insertions(+), 160 deletions(-) delete mode 100644 src/hooks/useConnections.ts create mode 100644 src/utils/connectionShortcuts.ts diff --git a/src/admin/apiProvider/authProvider.ts b/src/admin/apiProvider/authProvider.ts index 6a2d66fbf..c08b94e03 100644 --- a/src/admin/apiProvider/authProvider.ts +++ b/src/admin/apiProvider/authProvider.ts @@ -1,9 +1,8 @@ import { AuthProvider } from "react-admin"; import { isAdmin, UserRole } from "@/admin/apiProvider/utils/user"; -import { loginConnection, logout } from "@/connections/Login"; -import { myUserConnection } from "@/connections/User"; -import { loadConnection } from "@/utils/loadConnection"; +import { loadLogin, logout } from "@/connections/Login"; +import { loadMyUser } from "@/connections/User"; import Log from "@/utils/log"; export const authProvider: AuthProvider = { @@ -15,7 +14,7 @@ export const authProvider: AuthProvider = { // when the user navigates, make sure that their credentials are still valid checkAuth: async () => { - const { user } = await loadConnection(myUserConnection); + const { user } = await loadMyUser(); if (user == null) throw "No user logged in."; if (!isAdmin(user.primaryRole as UserRole)) throw "Only admins are allowed."; @@ -23,8 +22,7 @@ export const authProvider: AuthProvider = { // remove local credentials logout: async () => { - console.log("LOGOUT"); - const { isLoggedIn } = await loadConnection(loginConnection); + const { isLoggedIn } = await loadLogin(); if (isLoggedIn) { logout(); window.location.replace("/auth/login"); @@ -32,7 +30,7 @@ export const authProvider: AuthProvider = { }, getIdentity: async () => { - const { user } = await loadConnection(myUserConnection); + const { user } = await loadMyUser(); if (user == null) throw "No user logged in."; return { id: user.uuid, fullName: user.fullName, primaryRole: user.primaryRole }; diff --git a/src/admin/components/App.tsx b/src/admin/components/App.tsx index e552a531c..a3b4e8db5 100644 --- a/src/admin/components/App.tsx +++ b/src/admin/components/App.tsx @@ -6,15 +6,14 @@ import { dataProvider } from "@/admin/apiProvider/dataProviders"; import { AppLayout } from "@/admin/components/AppLayout"; import { theme } from "@/admin/components/theme"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; -import { myUserConnection } from "@/connections/User"; +import { useMyUser } from "@/connections/User"; import { LoadingProvider } from "@/context/loaderAdmin.provider"; -import { useConnection } from "@/hooks/useConnection"; import LoginPage from "@/pages/auth/login/index.page"; import modules from "../modules"; const App = () => { - const [, { user }] = useConnection(myUserConnection); + const [, { user }] = useMyUser(); if (user == null) return null; const canCreate = user.primaryRole === "admin-super"; diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx index c935dd433..a11f6f395 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/CommentarySection/CommentarySection.tsx @@ -3,8 +3,7 @@ import { When } from "react-if"; import CommentaryBox from "@/components/elements/CommentaryBox/CommentaryBox"; import Text from "@/components/elements/Text/Text"; import Loader from "@/components/generic/Loading/Loader"; -import { myUserConnection } from "@/connections/User"; -import { useConnection } from "@/hooks/useConnection"; +import { useMyUser } from "@/connections/User"; import { AuditLogEntity } from "../../../AuditLogTab/constants/types"; @@ -21,7 +20,7 @@ const CommentarySection = ({ viewCommentsList?: boolean; loading?: boolean; }) => { - const [, { user }] = useConnection(myUserConnection); + const [, { user }] = useMyUser(); return (
diff --git a/src/components/elements/Inputs/DataTable/RHFCoreTeamLeadersTable.tsx b/src/components/elements/Inputs/DataTable/RHFCoreTeamLeadersTable.tsx index 3e995668a..1a388cfb3 100644 --- a/src/components/elements/Inputs/DataTable/RHFCoreTeamLeadersTable.tsx +++ b/src/components/elements/Inputs/DataTable/RHFCoreTeamLeadersTable.tsx @@ -5,10 +5,9 @@ import { useController, UseControllerProps, UseFormReturn } from "react-hook-for import * as yup from "yup"; import { FieldType } from "@/components/extensive/WizardForm/types"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { getGenderOptions } from "@/constants/options/gender"; import { useDeleteV2CoreTeamLeaderUUID, usePostV2CoreTeamLeader } from "@/generated/apiComponents"; -import { useConnection } from "@/hooks/useConnection"; import { formatOptionsList } from "@/utils/options"; import DataTable, { DataTableProps } from "./DataTable"; @@ -46,7 +45,7 @@ const RHFCoreTeamLeadersDataTable = ({ const { field } = useController(props); const value = field?.value || []; - const [, { organisationId }] = useConnection(myOrganisationConnection); + const [, { organisationId }] = useMyOrg(); const { mutate: createTeamMember } = usePostV2CoreTeamLeader({ onSuccess(data) { diff --git a/src/components/elements/Inputs/DataTable/RHFFundingTypeDataTable.tsx b/src/components/elements/Inputs/DataTable/RHFFundingTypeDataTable.tsx index e40eee3d4..af2a84ba9 100644 --- a/src/components/elements/Inputs/DataTable/RHFFundingTypeDataTable.tsx +++ b/src/components/elements/Inputs/DataTable/RHFFundingTypeDataTable.tsx @@ -5,10 +5,9 @@ import { useController, UseControllerProps, UseFormReturn } from "react-hook-for import * as yup from "yup"; import { FieldType, FormField } from "@/components/extensive/WizardForm/types"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { getFundingTypesOptions } from "@/constants/options/fundingTypes"; import { useDeleteV2FundingTypeUUID, usePostV2FundingType } from "@/generated/apiComponents"; -import { useConnection } from "@/hooks/useConnection"; import { formatOptionsList } from "@/utils/options"; import DataTable, { DataTableProps } from "./DataTable"; @@ -89,7 +88,7 @@ const RHFFundingTypeDataTable = ({ onChangeCapture, ...props }: PropsWithChildre const { field } = useController(props); const value = field?.value || []; - const [, { organisationId }] = useConnection(myOrganisationConnection); + const [, { organisationId }] = useMyOrg(); const { mutate: createTeamMember } = usePostV2FundingType({ onSuccess(data) { diff --git a/src/components/elements/Inputs/DataTable/RHFLeadershipTeamTable.tsx b/src/components/elements/Inputs/DataTable/RHFLeadershipTeamTable.tsx index 5ddf04278..273b56643 100644 --- a/src/components/elements/Inputs/DataTable/RHFLeadershipTeamTable.tsx +++ b/src/components/elements/Inputs/DataTable/RHFLeadershipTeamTable.tsx @@ -5,10 +5,9 @@ import { useController, UseControllerProps, UseFormReturn } from "react-hook-for import * as yup from "yup"; import { FieldType } from "@/components/extensive/WizardForm/types"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { getGenderOptions } from "@/constants/options/gender"; import { useDeleteV2LeadershipTeamUUID, usePostV2LeadershipTeam } from "@/generated/apiComponents"; -import { useConnection } from "@/hooks/useConnection"; import { formatOptionsList } from "@/utils/options"; import DataTable, { DataTableProps } from "./DataTable"; @@ -43,7 +42,7 @@ const RHFLeadershipTeamDataTable = ({ onChangeCapture, ...props }: PropsWithChil const { field } = useController(props); const value = field?.value || []; - const [, { organisationId }] = useConnection(myOrganisationConnection); + const [, { organisationId }] = useMyOrg(); const { mutate: createTeamMember } = usePostV2LeadershipTeam({ onSuccess(data) { diff --git a/src/components/elements/Inputs/DataTable/RHFOwnershipStakeTable.tsx b/src/components/elements/Inputs/DataTable/RHFOwnershipStakeTable.tsx index 079c62d9a..866b5373a 100644 --- a/src/components/elements/Inputs/DataTable/RHFOwnershipStakeTable.tsx +++ b/src/components/elements/Inputs/DataTable/RHFOwnershipStakeTable.tsx @@ -5,10 +5,9 @@ import { useController, UseControllerProps, UseFormReturn } from "react-hook-for import * as yup from "yup"; import { FieldType } from "@/components/extensive/WizardForm/types"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { getGenderOptions } from "@/constants/options/gender"; import { useDeleteV2OwnershipStakeUUID, usePostV2OwnershipStake } from "@/generated/apiComponents"; -import { useConnection } from "@/hooks/useConnection"; import { formatOptionsList } from "@/utils/options"; import DataTable, { DataTableProps } from "./DataTable"; @@ -42,7 +41,7 @@ const RHFOwnershipStakeTable = ({ onChangeCapture, ...props }: PropsWithChildren const { field } = useController(props); const value = field?.value || []; - const [, { organisationId }] = useConnection(myOrganisationConnection); + const [, { organisationId }] = useMyOrg(); const { mutate: createTeamMember } = usePostV2OwnershipStake({ onSuccess(data) { diff --git a/src/components/extensive/Modal/ModalWithLogo.tsx b/src/components/extensive/Modal/ModalWithLogo.tsx index 41b9e6e58..abe0e6d37 100644 --- a/src/components/extensive/Modal/ModalWithLogo.tsx +++ b/src/components/extensive/Modal/ModalWithLogo.tsx @@ -14,10 +14,9 @@ import { formatCommentaryDate } from "@/components/elements/Map-mapbox/utils"; import StepProgressbar from "@/components/elements/ProgressBar/StepProgressbar/StepProgressbar"; import { StatusEnum } from "@/components/elements/Status/constants/statusMap"; import Text from "@/components/elements/Text/Text"; -import { myUserConnection } from "@/connections/User"; +import { useMyUser } from "@/connections/User"; import { GetV2AuditStatusENTITYUUIDResponse, useGetV2AuditStatusENTITYUUID } from "@/generated/apiComponents"; import { statusActionsMap } from "@/hooks/AuditStatus/useAuditLogActions"; -import { useConnection } from "@/hooks/useConnection"; import Icon, { IconNames } from "../Icon/Icon"; import { ModalProps } from "./Modal"; @@ -57,7 +56,7 @@ const ModalWithLogo: FC = ({ } }); - const [, { user }] = useConnection(myUserConnection); + const [, { user }] = useMyUser(); const [commentsAuditLogData, restAuditLogData] = useMemo(() => { const commentsAuditLog: GetV2AuditStatusENTITYUUIDResponse = []; diff --git a/src/components/extensive/WelcomeTour/WelcomeTour.tsx b/src/components/extensive/WelcomeTour/WelcomeTour.tsx index ccba59b52..bf192837a 100644 --- a/src/components/extensive/WelcomeTour/WelcomeTour.tsx +++ b/src/components/extensive/WelcomeTour/WelcomeTour.tsx @@ -4,10 +4,9 @@ import { FC, useCallback, useEffect, useMemo, useState } from "react"; import { When } from "react-if"; import Joyride, { Step } from "react-joyride"; -import { myUserConnection } from "@/connections/User"; +import { useMyUser } from "@/connections/User"; import { useModalContext } from "@/context/modal.provider"; import { useNavbarContext } from "@/context/navbar.provider"; -import { useConnection } from "@/hooks/useConnection"; import { ModalId } from "../Modal/ModalConst"; import ToolTip from "./Tooltip"; @@ -32,7 +31,7 @@ const WelcomeTour: FC = ({ tourId, tourSteps, onFinish, onStart, onDontS const { setIsOpen: setIsNavOpen, setLinksDisabled: setNavLinksDisabled } = useNavbarContext(); const isLg = useMediaQuery("(min-width:1024px)"); - const [, { user }] = useConnection(myUserConnection); + const [, { user }] = useMyUser(); const TOUR_COMPLETED_STORAGE_KEY = `${tourId}_${TOUR_COMPLETED_KEY}_${user?.uuid}`; const TOUR_SKIPPED_STORAGE_KEY = `${tourId}_${TOUR_SKIPPED_KEY}`; diff --git a/src/components/generic/Navbar/NavbarContent.tsx b/src/components/generic/Navbar/NavbarContent.tsx index 12f2ef7d1..6b97ccdb4 100644 --- a/src/components/generic/Navbar/NavbarContent.tsx +++ b/src/components/generic/Navbar/NavbarContent.tsx @@ -7,11 +7,10 @@ import { Else, If, Then, When } from "react-if"; import LanguagesDropdown from "@/components/elements/Inputs/LanguageDropdown/LanguagesDropdown"; import { IconNames } from "@/components/extensive/Icon/Icon"; import List from "@/components/extensive/List/List"; -import { loginConnection } from "@/connections/Login"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useLogin } from "@/connections/Login"; +import { useMyOrg } from "@/connections/Organisation"; import { useNavbarContext } from "@/context/navbar.provider"; import { useLogout } from "@/hooks/logout"; -import { useConnection } from "@/hooks/useConnection"; import { OptionValue } from "@/types/common"; import NavbarItem from "./NavbarItem"; @@ -22,10 +21,10 @@ interface NavbarContentProps extends DetailedHTMLProps { - const [, { isLoggedIn }] = useConnection(loginConnection); + const [, { isLoggedIn }] = useLogin(); const router = useRouter(); const t = useT(); - const [, myOrg] = useConnection(myOrganisationConnection); + const [, myOrg] = useMyOrg(); const logout = useLogout(); const { private: privateNavItems, public: publicNavItems } = getNavbarItems(t, myOrg); diff --git a/src/connections/Login.ts b/src/connections/Login.ts index 558020461..537e22b20 100644 --- a/src/connections/Login.ts +++ b/src/connections/Login.ts @@ -5,6 +5,7 @@ import { authLogin } from "@/generated/v3/userService/userServiceComponents"; import { authLoginFetchFailed, authLoginIsFetching } from "@/generated/v3/userService/userServicePredicates"; import ApiSlice, { ApiDataStore } from "@/store/apiSlice"; import { Connection } from "@/types/connection"; +import { connectionHook, connectionLoader, connectionSelector } from "@/utils/connectionShortcuts"; type LoginConnection = { isLoggingIn: boolean; @@ -23,7 +24,7 @@ export const logout = () => { const selectFirstLogin = (store: ApiDataStore) => Object.values(store.logins)?.[0]?.attributes; -export const loginConnection: Connection = { +const loginConnection: Connection = { selector: createSelector( [authLoginIsFetching, authLoginFetchFailed, selectFirstLogin], (isLoggingIn, failedLogin, firstLogin) => { @@ -36,3 +37,6 @@ export const loginConnection: Connection = { } ) }; +export const useLogin = connectionHook(loginConnection); +export const loadLogin = connectionLoader(loginConnection); +export const selectLogin = connectionSelector(loginConnection); diff --git a/src/connections/Organisation.ts b/src/connections/Organisation.ts index 9ace0032c..10b1a936d 100644 --- a/src/connections/Organisation.ts +++ b/src/connections/Organisation.ts @@ -4,6 +4,7 @@ import { selectMe } from "@/connections/User"; import { OrganisationDto } from "@/generated/v3/userService/userServiceSchemas"; import { ApiDataStore } from "@/store/apiSlice"; import { Connection } from "@/types/connection"; +import { connectionHook } from "@/utils/connectionShortcuts"; import { selectorCache } from "@/utils/selectorCache"; type OrganisationConnection = { @@ -27,7 +28,7 @@ const organisationSelector = (organisationId?: string) => (store: ApiDataStore) // TODO: This doesn't get a load/isLoaded until we have a v3 organisation get endpoint. For now we // have to rely on the data that is already in the store. We might not even end up needing this // connection, but it does illustrate nicely how to create a connection that takes props, so I'm -// leaving it in for now. +// leaving it in for now. Exported just to keep the linter happy since it's not currently used. export const organisationConnection: Connection = { selector: selectorCache( ({ organisationId }) => organisationId ?? "", @@ -42,7 +43,7 @@ export const organisationConnection: Connection = { +const myOrganisationConnection: Connection = { selector: createSelector([selectMe, selectOrganisations], (user, orgs) => { const { id, meta } = user?.relationships?.org?.[0] ?? {}; if (id == null) return {}; @@ -54,3 +55,4 @@ export const myOrganisationConnection: Connection = { }; }) }; +export const useMyOrg = connectionHook(myOrganisationConnection); diff --git a/src/connections/User.ts b/src/connections/User.ts index a3decc7ab..86748dfe9 100644 --- a/src/connections/User.ts +++ b/src/connections/User.ts @@ -5,6 +5,7 @@ import { usersFindFetchFailed } from "@/generated/v3/userService/userServicePred import { UserDto } from "@/generated/v3/userService/userServiceSchemas"; import { ApiDataStore, Relationships } from "@/store/apiSlice"; import { Connection } from "@/types/connection"; +import { connectionHook, connectionLoader } from "@/utils/connectionShortcuts"; type UserConnection = { user?: UserDto; @@ -20,7 +21,7 @@ export const selectMe = createSelector([selectMeId, selectUsers], (meId, users) const FIND_ME: UsersFindVariables = { pathParams: { id: "me" } }; -export const myUserConnection: Connection = { +const myUserConnection: Connection = { load: ({ user }) => { if (user == null) usersFind(FIND_ME); }, @@ -33,3 +34,5 @@ export const myUserConnection: Connection = { userLoadFailed: userLoadFailure != null })) }; +export const useMyUser = connectionHook(myUserConnection); +export const loadMyUser = connectionLoader(myUserConnection); diff --git a/src/constants/options/userFrameworksChoices.ts b/src/constants/options/userFrameworksChoices.ts index a3b4fd9c4..6c26a50d3 100644 --- a/src/constants/options/userFrameworksChoices.ts +++ b/src/constants/options/userFrameworksChoices.ts @@ -1,11 +1,10 @@ import { useMemo } from "react"; -import { myUserConnection } from "@/connections/User"; -import { useConnection } from "@/hooks/useConnection"; +import { useMyUser } from "@/connections/User"; import { OptionInputType } from "@/types/common"; export const useUserFrameworkChoices = (): OptionInputType[] => { - const [, { user }] = useConnection(myUserConnection); + const [, { user }] = useMyUser(); return useMemo(() => { return ( diff --git a/src/generated/apiContext.ts b/src/generated/apiContext.ts index b1bbb667a..5d6b74081 100644 --- a/src/generated/apiContext.ts +++ b/src/generated/apiContext.ts @@ -1,8 +1,7 @@ import type { QueryKey, UseQueryOptions } from "@tanstack/react-query"; import { QueryOperation } from "./apiComponents"; -import { useConnection } from "@/hooks/useConnection"; -import { loginConnection } from "@/connections/Login"; +import { useLogin } from "@/connections/Login"; export type ApiContext = { fetcherOptions: { @@ -41,7 +40,7 @@ export function useApiContext< TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey >(_queryOptions?: Omit, "queryKey" | "queryFn">): ApiContext { - const [, { token }] = useConnection(loginConnection); + const [, { token }] = useLogin(); return { fetcherOptions: { diff --git a/src/generated/v3/utils.ts b/src/generated/v3/utils.ts index fc9fa5d15..6378cd64c 100644 --- a/src/generated/v3/utils.ts +++ b/src/generated/v3/utils.ts @@ -1,7 +1,6 @@ import ApiSlice, { ApiDataStore, isErrorState, isInProgress, Method, PendingErrorState } from "@/store/apiSlice"; import Log from "@/utils/log"; -import { loginConnection } from "@/connections/Login"; -import { Connection, OptionalProps } from "@/types/connection"; +import { selectLogin } from "@/connections/Login"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; @@ -56,13 +55,6 @@ export function fetchFailed({ const isPending = (method: Method, fullUrl: string) => ApiSlice.apiDataStore.meta.pending[method][fullUrl] != null; -// We might want this utility more generally available. I'm hoping to avoid the need more widely, but I'm not totally -// opposed to this living in utils/ if we end up having a legitimate need for it. -const selectConnection = ( - connection: Connection, - props: P | Record = {} -) => connection.selector(ApiSlice.apiDataStore, props); - async function dispatchRequest(url: string, requestInit: RequestInit) { const actionPayload = { url, method: requestInit.method as Method }; ApiSlice.fetchStarting(actionPayload); @@ -138,7 +130,7 @@ export function serviceFetch< // store, which means that the next connections that kick off right away don't have access to // the token through the getAccessToken method. So, we grab it from the store instead, which is // more reliable in this case. - const { token } = selectConnection(loginConnection); + const { token } = selectLogin(); if (!requestHeaders?.Authorization && token != null) { // Always include the JWT access token if we have one. requestHeaders.Authorization = `Bearer ${token}`; diff --git a/src/hooks/useConnection.ts b/src/hooks/useConnection.ts index 8b31f294b..ee7db446a 100644 --- a/src/hooks/useConnection.ts +++ b/src/hooks/useConnection.ts @@ -50,5 +50,5 @@ export function useConnection( - connections: [Connection, Connection], - props?: P1 & P2 -): readonly [boolean, [S1, S2]]; -export function useConnections< - S1, - S2, - S3, - P1 extends OptionalProps, - P2 extends OptionalProps, - P3 extends OptionalProps ->( - connections: [Connection, Connection, Connection], - props?: P1 & P2 & P3 -): readonly [boolean, [S1, S2, S3]]; -export function useConnections< - S1, - S2, - S3, - S4, - P1 extends OptionalProps, - P2 extends OptionalProps, - P3 extends OptionalProps, - P4 extends OptionalProps ->( - connections: [Connection, Connection, Connection, Connection], - props?: P1 & P2 & P3 & P4 -): readonly [boolean, [S1, S2, S3, S4]]; -export function useConnections< - S1, - S2, - S3, - S4, - S5, - P1 extends OptionalProps, - P2 extends OptionalProps, - P3 extends OptionalProps, - P4 extends OptionalProps, - P5 extends OptionalProps ->( - connections: [Connection, Connection, Connection, Connection, Connection], - props?: P1 & P2 & P3 & P4 & P5 -): readonly [boolean, [S1, S2, S3, S4, S5]]; - -/** - * A convenience hook to depend on multiple connections, and receive a single "loaded" flag for all of them. - */ -export function useConnections( - connections: Connection[], - props: Record = {} -): readonly [boolean, unknown[]] { - const numConnections = useRef(connections.length); - if (numConnections.current !== connections.length) { - // We're violating the rules of hooks by running hooks in a loop below, so let's scream about - // it extra loud if the number of connections changes. - Log.error("NUMBER OF CONNECTIONS CHANGED!", { original: numConnections.current, current: connections.length }); - } - - return connections.reduce( - ([allLoaded, connecteds], connection) => { - const [loaded, connected] = useConnection(connection, props); - return [loaded && allLoaded, [...connecteds, connected]]; - }, - [true, []] as readonly [boolean, unknown[]] - ); -} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 0dbb82d4d..e823f7905 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -15,8 +15,8 @@ import Toast from "@/components/elements/Toast/Toast"; import ModalRoot from "@/components/extensive/Modal/ModalRoot"; import DashboardLayout from "@/components/generic/Layout/DashboardLayout"; import MainLayout from "@/components/generic/Layout/MainLayout"; -import { loginConnection } from "@/connections/Login"; -import { myUserConnection } from "@/connections/User"; +import { loadLogin } from "@/connections/Login"; +import { loadMyUser } from "@/connections/User"; import { LoadingProvider } from "@/context/loaderAdmin.provider"; import ModalProvider from "@/context/modal.provider"; import NavbarProvider from "@/context/navbar.provider"; @@ -27,7 +27,6 @@ import ToastProvider from "@/context/toast.provider"; import { getServerSideTranslations, setClientSideTranslations } from "@/i18n"; import { apiSlice } from "@/store/apiSlice"; import { wrapper } from "@/store/store"; -import { loadConnection } from "@/utils/loadConnection"; import Log from "@/utils/log"; import setupYup from "@/yup.locale"; @@ -102,9 +101,9 @@ const _App = ({ Component, ...rest }: AppProps) => { _App.getInitialProps = wrapper.getInitialAppProps(store => async (context: AppContext) => { const authToken = nookies.get(context.ctx).accessToken; - if (authToken != null && (await loadConnection(loginConnection)).token !== authToken) { + if (authToken != null && (await loadLogin()).token !== authToken) { store.dispatch(apiSlice.actions.setInitialAuthToken({ authToken })); - await loadConnection(myUserConnection); + await loadMyUser(); } const ctx = await App.getInitialProps(context); diff --git a/src/pages/auth/login/index.page.tsx b/src/pages/auth/login/index.page.tsx index 9f7fdb55f..3f7ae4213 100644 --- a/src/pages/auth/login/index.page.tsx +++ b/src/pages/auth/login/index.page.tsx @@ -4,9 +4,8 @@ import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import * as yup from "yup"; -import { login, loginConnection } from "@/connections/Login"; +import { login, useLogin } from "@/connections/Login"; import { ToastType, useToastContext } from "@/context/toast.provider"; -import { useConnection } from "@/hooks/useConnection"; import { useSetInviteToken } from "@/hooks/useInviteToken"; import { useValueChanged } from "@/hooks/useValueChanged"; @@ -29,7 +28,7 @@ const LoginPage = () => { useSetInviteToken(); const t = useT(); const router = useRouter(); - const [, { isLoggedIn, isLoggingIn, loginFailed }] = useConnection(loginConnection); + const [, { isLoggedIn, isLoggingIn, loginFailed }] = useLogin(); const { openToast } = useToastContext(); const form = useForm({ resolver: yupResolver(LoginFormDataSchema(t)), diff --git a/src/pages/form/[id]/pitch-select.page.tsx b/src/pages/form/[id]/pitch-select.page.tsx index fdf010633..a3f6c999a 100644 --- a/src/pages/form/[id]/pitch-select.page.tsx +++ b/src/pages/form/[id]/pitch-select.page.tsx @@ -13,10 +13,9 @@ import Form from "@/components/extensive/Form/Form"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { useGetV2FormsUUID, useGetV2ProjectPitches, usePostV2FormsSubmissions } from "@/generated/apiComponents"; import { FormRead } from "@/generated/apiSchemas"; -import { useConnection } from "@/hooks/useConnection"; import { useDate } from "@/hooks/useDate"; const schema = yup.object({ @@ -30,7 +29,7 @@ const FormIntroPage = () => { const t = useT(); const router = useRouter(); const { format } = useDate(); - const [, { organisationId }] = useConnection(myOrganisationConnection); + const [, { organisationId }] = useMyOrg(); const formUUID = router.query.id as string; diff --git a/src/pages/home.page.tsx b/src/pages/home.page.tsx index 20f4b962b..252fabf3c 100644 --- a/src/pages/home.page.tsx +++ b/src/pages/home.page.tsx @@ -14,15 +14,14 @@ import TaskList from "@/components/extensive/TaskList/TaskList"; import { useGetHomeTourItems } from "@/components/extensive/WelcomeTour/useGetHomeTourItems"; import WelcomeTour from "@/components/extensive/WelcomeTour/WelcomeTour"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { useGetV2FundingProgramme } from "@/generated/apiComponents"; -import { useConnection } from "@/hooks/useConnection"; import { useAcceptInvitation } from "@/hooks/useInviteToken"; import { fundingProgrammeToFundingCardProps } from "@/utils/dataTransformation"; const HomePage = () => { const t = useT(); - const [, { organisation, organisationId }] = useConnection(myOrganisationConnection); + const [, { organisation, organisationId }] = useMyOrg(); const route = useRouter(); const tourSteps = useGetHomeTourItems(); useAcceptInvitation(); diff --git a/src/pages/my-projects/index.page.tsx b/src/pages/my-projects/index.page.tsx index 82973b396..02fbc5ac8 100644 --- a/src/pages/my-projects/index.page.tsx +++ b/src/pages/my-projects/index.page.tsx @@ -15,14 +15,13 @@ import PageFooter from "@/components/extensive/PageElements/Footer/PageFooter"; import PageHeader from "@/components/extensive/PageElements/Header/PageHeader"; import PageSection from "@/components/extensive/PageElements/Section/PageSection"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { ToastType, useToastContext } from "@/context/toast.provider"; import { GetV2MyProjectsResponse, useDeleteV2ProjectsUUID, useGetV2MyProjects } from "@/generated/apiComponents"; -import { useConnection } from "@/hooks/useConnection"; const MyProjectsPage = () => { const t = useT(); - const [, { organisation }] = useConnection(myOrganisationConnection); + const [, { organisation }] = useMyOrg(); const { openToast } = useToastContext(); const { data: projectsData, isLoading, refetch } = useGetV2MyProjects<{ data: GetV2MyProjectsResponse }>({}); diff --git a/src/pages/opportunities/index.page.tsx b/src/pages/opportunities/index.page.tsx index 658d127ae..6b3fa5dc4 100644 --- a/src/pages/opportunities/index.page.tsx +++ b/src/pages/opportunities/index.page.tsx @@ -18,15 +18,14 @@ import PageSection from "@/components/extensive/PageElements/Section/PageSection import ApplicationsTable from "@/components/extensive/Tables/ApplicationsTable"; import PitchesTable from "@/components/extensive/Tables/PitchesTable"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { useGetV2FundingProgramme, useGetV2MyApplications } from "@/generated/apiComponents"; -import { useConnection } from "@/hooks/useConnection"; import { fundingProgrammeToFundingCardProps } from "@/utils/dataTransformation"; const OpportunitiesPage = () => { const t = useT(); const route = useRouter(); - const [, { organisation, organisationId }] = useConnection(myOrganisationConnection); + const [, { organisation, organisationId }] = useMyOrg(); const [pitchesCount, setPitchesCount] = useState(); const { data: fundingProgrammes, isLoading: loadingFundingProgrammes } = useGetV2FundingProgramme({ diff --git a/src/pages/organization/create/index.page.tsx b/src/pages/organization/create/index.page.tsx index 87010aab5..1b3573abc 100644 --- a/src/pages/organization/create/index.page.tsx +++ b/src/pages/organization/create/index.page.tsx @@ -7,7 +7,7 @@ import { ModalId } from "@/components/extensive/Modal/ModalConst"; import WizardForm from "@/components/extensive/WizardForm"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { useModalContext } from "@/context/modal.provider"; import { useDeleteV2OrganisationsRetractMyDraft, @@ -16,7 +16,6 @@ import { usePutV2OrganisationsUUID } from "@/generated/apiComponents"; import { V2OrganisationRead } from "@/generated/apiSchemas"; -import { useConnection } from "@/hooks/useConnection"; import { useNormalizedFormDefaultValue } from "@/hooks/useGetCustomFormSteps/useGetCustomFormSteps"; import { getSteps } from "./getCreateOrganisationSteps"; @@ -24,7 +23,7 @@ import { getSteps } from "./getCreateOrganisationSteps"; const CreateOrganisationForm = () => { const t = useT(); const router = useRouter(); - const [, { organisationId }] = useConnection(myOrganisationConnection); + const [, { organisationId }] = useMyOrg(); const { openModal, closeModal } = useModalContext(); const queryClient = useQueryClient(); diff --git a/src/pages/organization/status/pending.page.tsx b/src/pages/organization/status/pending.page.tsx index cc0b04aa4..145424abb 100644 --- a/src/pages/organization/status/pending.page.tsx +++ b/src/pages/organization/status/pending.page.tsx @@ -5,12 +5,11 @@ import HandsPlantingImage from "public/images/hands-planting.webp"; import Text from "@/components/elements/Text/Text"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; -import { myOrganisationConnection } from "@/connections/Organisation"; -import { useConnection } from "@/hooks/useConnection"; +import { useMyOrg } from "@/connections/Organisation"; const OrganizationPendingPage = () => { const t = useT(); - const [, { organisation }] = useConnection(myOrganisationConnection); + const [, { organisation }] = useMyOrg(); return ( diff --git a/src/pages/organization/status/rejected.page.tsx b/src/pages/organization/status/rejected.page.tsx index 2ced19e31..3203ef1b7 100644 --- a/src/pages/organization/status/rejected.page.tsx +++ b/src/pages/organization/status/rejected.page.tsx @@ -6,12 +6,11 @@ import Text from "@/components/elements/Text/Text"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import BackgroundLayout from "@/components/generic/Layout/BackgroundLayout"; import ContentLayout from "@/components/generic/Layout/ContentLayout"; -import { myOrganisationConnection } from "@/connections/Organisation"; +import { useMyOrg } from "@/connections/Organisation"; import { zendeskSupportLink } from "@/constants/links"; -import { useConnection } from "@/hooks/useConnection"; const OrganizationRejectedPage = () => { - const [, { organisation }] = useConnection(myOrganisationConnection); + const [, { organisation }] = useMyOrg(); const t = useT(); return ( diff --git a/src/types/connection.ts b/src/types/connection.ts index 54612872a..3fc05d7c8 100644 --- a/src/types/connection.ts +++ b/src/types/connection.ts @@ -13,4 +13,4 @@ export type Connection void; }; -export type Connected = readonly [boolean, SelectedType | Record]; +export type Connected = readonly [true, SelectedType] | readonly [false, Record]; diff --git a/src/utils/connectionShortcuts.ts b/src/utils/connectionShortcuts.ts new file mode 100644 index 000000000..49366a9bd --- /dev/null +++ b/src/utils/connectionShortcuts.ts @@ -0,0 +1,30 @@ +import { useConnection } from "@/hooks/useConnection"; +import ApiSlice from "@/store/apiSlice"; +import { Connection, OptionalProps } from "@/types/connection"; +import { loadConnection } from "@/utils/loadConnection"; + +/** + * Generates a hook for using this specific connection. + */ +export function connectionHook(connection: Connection) { + return (props: TProps | Record = {}) => useConnection(connection, props); +} + +/** + * Generates an async loader for this specific connection. Awaiting on the loader will not return + * until the connection is in a valid loaded state. + */ +export function connectionLoader(connection: Connection) { + return (props: TProps | Record = {}) => loadConnection(connection, props); +} + +/** + * Generates a synchronous selector for this specific connection. Ignores loaded state and simply + * returns the current connection state with whatever is currently cached in the store. + * + * Note: Use sparingly! There are very few cases where this type of connection access is actually + * desirable. In almost every case, connectionHook or connectionLoader is what you really want. + */ +export function connectionSelector(connection: Connection) { + return (props: TProps | Record = {}) => connection.selector(ApiSlice.apiDataStore, props); +} From f6c9f27baad0da42abb54157096f5d071e81655b Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 30 Sep 2024 17:09:38 -0700 Subject: [PATCH 064/102] [TM-1312] Make sure users/me is loaded when the useMyOrg hook is in use. (cherry picked from commit 5610324b6a91a7c88059adc314324239fb6efdb6) --- src/admin/apiProvider/authProvider.ts | 5 +---- src/connections/Login.ts | 3 ++- src/connections/Organisation.ts | 17 +++++++++------- src/connections/User.ts | 28 ++++++++++++++++----------- src/store/apiSlice.ts | 27 +++++++++++++------------- src/utils/loadConnection.ts | 3 +-- 6 files changed, 45 insertions(+), 38 deletions(-) diff --git a/src/admin/apiProvider/authProvider.ts b/src/admin/apiProvider/authProvider.ts index c08b94e03..c666ce58c 100644 --- a/src/admin/apiProvider/authProvider.ts +++ b/src/admin/apiProvider/authProvider.ts @@ -23,10 +23,7 @@ export const authProvider: AuthProvider = { // remove local credentials logout: async () => { const { isLoggedIn } = await loadLogin(); - if (isLoggedIn) { - logout(); - window.location.replace("/auth/login"); - } + if (isLoggedIn) logout(); }, getIdentity: async () => { diff --git a/src/connections/Login.ts b/src/connections/Login.ts index 537e22b20..bf54ca07a 100644 --- a/src/connections/Login.ts +++ b/src/connections/Login.ts @@ -20,9 +20,10 @@ export const logout = () => { // When we log out, remove all cached API resources so that when we log in again, these resources // are freshly fetched from the BE. ApiSlice.clearApiCache(); + window.location.replace("/auth/login"); }; -const selectFirstLogin = (store: ApiDataStore) => Object.values(store.logins)?.[0]?.attributes; +export const selectFirstLogin = (store: ApiDataStore) => Object.values(store.logins)?.[0]?.attributes; const loginConnection: Connection = { selector: createSelector( diff --git a/src/connections/Organisation.ts b/src/connections/Organisation.ts index 10b1a936d..a4d7971e9 100644 --- a/src/connections/Organisation.ts +++ b/src/connections/Organisation.ts @@ -1,10 +1,10 @@ import { createSelector } from "reselect"; -import { selectMe } from "@/connections/User"; +import { selectMe, useMyUser } from "@/connections/User"; import { OrganisationDto } from "@/generated/v3/userService/userServiceSchemas"; +import { useConnection } from "@/hooks/useConnection"; import { ApiDataStore } from "@/store/apiSlice"; import { Connection } from "@/types/connection"; -import { connectionHook } from "@/utils/connectionShortcuts"; import { selectorCache } from "@/utils/selectorCache"; type OrganisationConnection = { @@ -39,10 +39,6 @@ export const organisationConnection: Connection = { selector: createSelector([selectMe, selectOrganisations], (user, orgs) => { const { id, meta } = user?.relationships?.org?.[0] ?? {}; @@ -55,4 +51,11 @@ const myOrganisationConnection: Connection = { }; }) }; -export const useMyOrg = connectionHook(myOrganisationConnection); +// The "myOrganisationConnection" is only valid once the users/me response has been loaded, so +// this hook depends on the myUserConnection to fetch users/me and then loads the data it needs +// from the store. +export const useMyOrg = () => { + const [loaded] = useMyUser(); + const [, orgShape] = useConnection(myOrganisationConnection); + return [loaded, orgShape]; +}; diff --git a/src/connections/User.ts b/src/connections/User.ts index 86748dfe9..36f65f448 100644 --- a/src/connections/User.ts +++ b/src/connections/User.ts @@ -1,16 +1,19 @@ import { createSelector } from "reselect"; +import { selectFirstLogin } from "@/connections/Login"; import { usersFind, UsersFindVariables } from "@/generated/v3/userService/userServiceComponents"; import { usersFindFetchFailed } from "@/generated/v3/userService/userServicePredicates"; import { UserDto } from "@/generated/v3/userService/userServiceSchemas"; -import { ApiDataStore, Relationships } from "@/store/apiSlice"; +import { ApiDataStore } from "@/store/apiSlice"; import { Connection } from "@/types/connection"; import { connectionHook, connectionLoader } from "@/utils/connectionShortcuts"; type UserConnection = { user?: UserDto; - userRelationships?: Relationships; userLoadFailed: boolean; + + /** Used internally by the connection to determine if an attempt to load users/me should happen or not. */ + isLoggedIn: boolean; }; const selectMeId = (store: ApiDataStore) => store.meta.meUserId; @@ -21,18 +24,21 @@ export const selectMe = createSelector([selectMeId, selectUsers], (meId, users) const FIND_ME: UsersFindVariables = { pathParams: { id: "me" } }; -const myUserConnection: Connection = { - load: ({ user }) => { - if (user == null) usersFind(FIND_ME); +export const myUserConnection: Connection = { + load: ({ isLoggedIn, user }) => { + if (user == null && isLoggedIn) usersFind(FIND_ME); }, - isLoaded: ({ user, userLoadFailed }) => userLoadFailed || user != null, + isLoaded: ({ user, userLoadFailed, isLoggedIn }) => !isLoggedIn || userLoadFailed || user != null, - selector: createSelector([selectMe, usersFindFetchFailed(FIND_ME)], (resource, userLoadFailure) => ({ - user: resource?.attributes, - userRelationships: resource?.relationships, - userLoadFailed: userLoadFailure != null - })) + selector: createSelector( + [selectMe, selectFirstLogin, usersFindFetchFailed(FIND_ME)], + (resource, firstLogin, userLoadFailure) => ({ + user: resource?.attributes, + userLoadFailed: userLoadFailure != null, + isLoggedIn: firstLogin?.token != null + }) + ) }; export const useMyUser = connectionHook(myUserConnection); export const loadMyUser = connectionLoader(myUserConnection); diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index af9fadc86..a63d99d99 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -5,7 +5,6 @@ import { HYDRATE } from "next-redux-wrapper"; import { Store } from "redux"; import { setAccessToken } from "@/admin/apiProvider/utils/token"; -import { usersFind } from "@/generated/v3/userService/userServiceComponents"; import { LoginDto, OrganisationDto, UserDto } from "@/generated/v3/userService/userServiceSchemas"; export type PendingErrorState = { @@ -124,8 +123,6 @@ const clearApiCache = (state: WritableDraft) => { const isLogin = ({ url, method }: { url: string; method: Method }) => url.endsWith("auth/v3/logins") && method === "POST"; -const reloadMe = () => setTimeout(() => usersFind({ pathParams: { id: "me" } }), 0); - export const apiSlice = createSlice({ name: "api", @@ -146,9 +143,6 @@ export const apiSlice = createSlice({ // After a successful login, clear the entire cache; we want all mounted components to // re-fetch their data with the new login credentials. clearApiCache(state); - // TODO: this will no longer be needed once we have connection chaining, as the my org - // connection will force the my user connection to load. - reloadMe(); } else { delete state.meta.pending[method][url]; } @@ -195,20 +189,27 @@ export const apiSlice = createSlice({ extraReducers: builder => { builder.addCase(HYDRATE, (state, action) => { - clearApiCache(state); - - const { payload } = action as unknown as PayloadAction<{ api: ApiDataStore }>; + const { + payload: { api: payloadState } + } = action as unknown as PayloadAction<{ api: ApiDataStore }>; + + if (state.meta.meUserId !== payloadState.meta.meUserId) { + // It's likely the server hasn't loaded as many resources as the client. We should only + // clear out our cached client-side state if the server claims to have a different logged-in + // user state than we do. + clearApiCache(state); + } for (const resource of RESOURCES) { - state[resource] = payload.api[resource] as any; + state[resource] = payloadState[resource] as StoreResourceMap; } for (const method of METHODS) { - state.meta.pending[method] = payload.api.meta.pending[method]; + state.meta.pending[method] = payloadState.meta.pending[method]; } - if (payload.api.meta.meUserId != null) { - state.meta.meUserId = payload.api.meta.meUserId; + if (payloadState.meta.meUserId != null) { + state.meta.meUserId = payloadState.meta.meUserId; } }); } diff --git a/src/utils/loadConnection.ts b/src/utils/loadConnection.ts index f4912829e..08870d80f 100644 --- a/src/utils/loadConnection.ts +++ b/src/utils/loadConnection.ts @@ -11,8 +11,7 @@ export async function loadConnection { const connected = selector(store, props); const loaded = isLoaded == null || isLoaded(connected, props); - // Delay to avoid calling dispatch during store update resolution - if (!loaded && load != null) setTimeout(() => load(connected, props), 0); + if (!loaded && load != null) load(connected, props); return loaded; }; From 3be91bc3451bd0b2d20f080f07f4764e280d77ea Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 7 Oct 2024 10:39:54 -0700 Subject: [PATCH 065/102] [TM-1312] Fix the useConnection test. (cherry picked from commit d5cee9aca6e349cfc13bda715a39cb54f3737fd6) --- src/hooks/useConnection.test.tsx | 18 +++++++++++------- src/store/store.ts | 4 ++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/hooks/useConnection.test.tsx b/src/hooks/useConnection.test.tsx index ce7e92b60..553412273 100644 --- a/src/hooks/useConnection.test.tsx +++ b/src/hooks/useConnection.test.tsx @@ -1,15 +1,19 @@ import { renderHook } from "@testing-library/react"; -import { ReactNode } from "react"; +import { ReactNode, useMemo } from "react"; import { act } from "react-dom/test-utils"; +import { Provider as ReduxProvider } from "react-redux"; import { createSelector } from "reselect"; -import { LoginResponse } from "@/generated/v3/userService/userServiceSchemas"; +import { AuthLoginResponse } from "@/generated/v3/userService/userServiceComponents"; import { useConnection } from "@/hooks/useConnection"; import ApiSlice, { ApiDataStore, JsonApiResource } from "@/store/apiSlice"; -import StoreProvider from "@/store/StoreProvider"; +import { makeStore } from "@/store/store"; import { Connection } from "@/types/connection"; -const StoreWrapper = ({ children }: { children: ReactNode }) => {children}; +const StoreWrapper = ({ children }: { children: ReactNode }) => { + const store = useMemo(() => makeStore(), []); + return {children}; +}; describe("Test useConnection hook", () => { test("isLoaded", () => { @@ -40,7 +44,7 @@ describe("Test useConnection hook", () => { }); const connection = { selector: createSelector([selector], payloadCreator) - } as Connection<{ login: LoginResponse }>; + } as Connection<{ login: AuthLoginResponse }>; const { result, rerender } = renderHook(() => useConnection(connection), { wrapper: StoreWrapper }); rerender(); @@ -52,7 +56,7 @@ describe("Test useConnection hook", () => { expect(payloadCreator).toHaveBeenCalledTimes(1); const token = "asdfasdfasdf"; - const data = { type: "logins", id: "1", token } as JsonApiResource; + const data = { type: "logins", id: "1", attributes: { token } } as JsonApiResource; act(() => { ApiSlice.fetchSucceeded({ url: "/foo", method: "POST", response: { data } }); }); @@ -60,7 +64,7 @@ describe("Test useConnection hook", () => { // The store has changed so the selector gets called again, and the selector's result has // changed so the payload creator gets called again, and returns the new Login response that // was saved in the store. - expect(result.current[1]).toStrictEqual({ login: data }); + expect(result.current[1]).toStrictEqual({ login: { attributes: { token } } }); expect(selector).toHaveBeenCalledTimes(2); expect(payloadCreator).toHaveBeenCalledTimes(2); diff --git a/src/store/store.ts b/src/store/store.ts index 818ee3bf5..4b9679305 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -9,7 +9,7 @@ export type AppStore = { api: ApiDataStore; }; -const makeStore: MakeStore> = () => { +export const makeStore = () => { const store = configureStore({ reducer: { api: apiSlice.reducer @@ -39,4 +39,4 @@ const makeStore: MakeStore> = () => { return store; }; -export const wrapper = createWrapper>(makeStore); +export const wrapper = createWrapper>(makeStore as MakeStore>); From ee37b17b75409ab22c3970de2ab0a539fe65e037 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 7 Oct 2024 11:26:03 -0700 Subject: [PATCH 066/102] [TM-1312] Fix the middleware tests. (cherry picked from commit c10af9b9b38030deeb4a35b0e04907daf3c08ed3) --- .../v3/userService/userServiceComponents.ts | 5 +- .../v3/userService/userServiceSchemas.ts | 12 +- src/middleware.page.ts | 6 +- src/middleware.test.ts | 146 ++++++------------ 4 files changed, 62 insertions(+), 107 deletions(-) diff --git a/src/generated/v3/userService/userServiceComponents.ts b/src/generated/v3/userService/userServiceComponents.ts index 38fcb11c5..6a60c063d 100644 --- a/src/generated/v3/userService/userServiceComponents.ts +++ b/src/generated/v3/userService/userServiceComponents.ts @@ -55,6 +55,9 @@ export const authLogin = (variables: AuthLoginVariables, signal?: AbortSignal) = }); export type UsersFindPathParams = { + /** + * A valid user id or "me" + */ id: string; }; @@ -117,7 +120,7 @@ export type UsersFindResponse = { */ id?: string; meta?: { - userStatus?: "approved" | "requested" | "rejected"; + userStatus?: "approved" | "requested" | "rejected" | "na"; }; }; }; diff --git a/src/generated/v3/userService/userServiceSchemas.ts b/src/generated/v3/userService/userServiceSchemas.ts index bd957da65..5c8c36617 100644 --- a/src/generated/v3/userService/userServiceSchemas.ts +++ b/src/generated/v3/userService/userServiceSchemas.ts @@ -30,12 +30,12 @@ export type UserFramework = { export type UserDto = { uuid: string; - firstName: string; - lastName: string; + firstName: string | null; + lastName: string | null; /** * Currently just calculated by appending lastName to firstName. */ - fullName: string; + fullName: string | null; primaryRole: string; /** * @example person@foocorp.net @@ -44,12 +44,12 @@ export type UserDto = { /** * @format date-time */ - emailAddressVerifiedAt: string; - locale: string; + emailAddressVerifiedAt: string | null; + locale: string | null; frameworks: UserFramework[]; }; export type OrganisationDto = { status: "draft" | "pending" | "approved" | "rejected"; - name: string; + name: string | null; }; diff --git a/src/middleware.page.ts b/src/middleware.page.ts index 492551c24..28a01f9d5 100644 --- a/src/middleware.page.ts +++ b/src/middleware.page.ts @@ -2,7 +2,7 @@ import * as Sentry from "@sentry/nextjs"; import { NextRequest } from "next/server"; import { isAdmin, UserRole } from "@/admin/apiProvider/utils/user"; -import { UserDto } from "@/generated/v3/userService/userServiceSchemas"; +import { OrganisationDto, UserDto } from "@/generated/v3/userService/userServiceSchemas"; import { resolveUrl } from "@/generated/v3/utils"; import { MiddlewareCacheKey, MiddlewareMatcher } from "@/utils/MiddlewareMatcher"; @@ -51,8 +51,8 @@ export async function middleware(request: NextRequest) { const { id: organisationId, meta: { userStatus } - } = json.data.relationships.org.data; - const organisation = json.included[0]; + } = json.data.relationships?.org?.data ?? { meta: {} }; + const organisation: OrganisationDto | undefined = json.included?.[0]?.attributes; matcher.if( !user?.emailAddressVerifiedAt, diff --git a/src/middleware.test.ts b/src/middleware.test.ts index d6a05dfdb..a178dec33 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -4,18 +4,13 @@ import { NextRequest, NextResponse } from "next/server"; -import * as api from "@/generated/apiComponents"; +import { OrganisationDto, UserDto } from "@/generated/v3/userService/userServiceSchemas"; import { middleware } from "./middleware.page"; //@ts-ignore Headers.prototype.getAll = () => []; //To fix TypeError: this._headers.getAll is not a function -jest.mock("@/generated/apiComponents", () => ({ - __esModule: true, - fetchGetAuthMe: jest.fn() -})); - const domain = "https://localhost:3000"; const getRequest = (url: string, loggedIn?: boolean, cachedUrl?: string) => { @@ -87,20 +82,44 @@ describe("User is not Logged In", () => { }); }); +function mockUsersMe( + userAttributes: Partial, + org: { + attributes?: Partial; + id?: string; + userStatus?: string; + } = {} +) { + jest.spyOn(global, "fetch").mockImplementation(() => + Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + data: { + attributes: userAttributes, + relationships: { + org: { + data: { + id: org.id, + meta: { userStatus: org.userStatus } + } + } + } + }, + included: [{ attributes: org.attributes }] + }) + } as Response) + ); +} + describe("User is Logged In and not verified", () => { - beforeEach(() => { + afterEach(() => { jest.resetAllMocks(); }); it("redirect not verified users to /auth/signup/confirm", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address: "test@example.com", - email_address_verified_at: null - } - }); + mockUsersMe({ emailAddress: "test@example.com" }); await middleware(getRequest("/", true)); await testMultipleRoute(spy, `/auth/signup/confirm?email=test@example.com`); @@ -108,21 +127,13 @@ describe("User is Logged In and not verified", () => { }); describe("User is Logged In and verified", () => { - beforeEach(() => { + afterEach(() => { jest.resetAllMocks(); }); it("redirect routes that start with /organization/create to /organization/create/confirm when org has been approved", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: { - status: "approved" - } - } - }); + mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { attributes: { status: "approved" } }); await middleware(getRequest("/organization/create/test", true)); expect(spy).toBeCalledWith(new URL("/organization/create/confirm", domain)); @@ -133,86 +144,45 @@ describe("User is Logged In and verified", () => { it("redirect any route to /admin when user is an admin", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: null, - role: "admin-super" - } - }); + mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z", primaryRole: "admin-super" }); + await testMultipleRoute(spy, "/admin"); }); it("redirect any route to /organization/assign when org does not exist", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: null - } - }); + mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }); + await testMultipleRoute(spy, "/organization/assign"); }); it("redirect any route to /organization/create when org is a draft", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: { - status: "draft" - } - } - }); + mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { attributes: { status: "draft" } }); + await testMultipleRoute(spy, "/organization/create"); }); it("redirect any route to /organization/status/pending when user is awaiting org approval", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: { - users_status: "requested" - } - } - }); + mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { userStatus: "requested" }); await testMultipleRoute(spy, "/organization/status/pending"); }); it("redirect any route to /organization/status/rejected when user is rejected", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: { - status: "rejected" - } - } - }); + mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { attributes: { status: "rejected" } }); await testMultipleRoute(spy, "/organization/status/rejected"); }); it("redirect /organization to /organization/[org_uuid]", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: { - status: "approved", - name: "", - uuid: "uuid" - } - } - }); + mockUsersMe( + { emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, + { attributes: { status: "approved" }, id: "uuid" } + ); await middleware(getRequest("/organization", true)); @@ -221,16 +191,7 @@ describe("User is Logged In and verified", () => { it("redirect / to /home", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: { - status: "approved", - name: "" - } - } - }); + mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { attributes: { status: "approved" } }); await middleware(getRequest("/", true)); @@ -239,16 +200,7 @@ describe("User is Logged In and verified", () => { it("redirect routes that startWith /auth to /home", async () => { const spy = jest.spyOn(NextResponse, "redirect"); - //@ts-ignore - api.fetchGetAuthMe.mockResolvedValue({ - data: { - email_address_verified_at: "2023-02-17T10:54:16.000000Z", - organisation: { - status: "approved", - name: "" - } - } - }); + mockUsersMe({ emailAddressVerifiedAt: "2023-02-17T10:54:16.000000Z" }, { attributes: { status: "approved" } }); await middleware(getRequest("/auth", true)); expect(spy).toBeCalledWith(new URL("/home", domain)); From eae6afba78fae7a337bbf354cae1bfddd40366b3 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 7 Oct 2024 14:51:02 -0700 Subject: [PATCH 067/102] [TM-1312] Fix up storybook usage of connections. (cherry picked from commit 358fe679dbb46c8c3b3f293b443cbd6e35ab9501) --- .storybook/preview.js | 9 +--- README.md | 12 ++++++ .../generic/Navbar/Navbar.stories.tsx | 4 +- src/connections/Organisation.ts | 6 +-- src/store/apiSlice.ts | 4 +- src/utils/testStore.tsx | 41 +++++++++++++++++++ 6 files changed, 63 insertions(+), 13 deletions(-) create mode 100644 src/utils/testStore.tsx diff --git a/.storybook/preview.js b/.storybook/preview.js index f9f8b88a2..fe8f4a4e0 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,6 +1,6 @@ import "src/styles/globals.css"; import * as NextImage from "next/image"; -import StoreProvider from "../src/store/StoreProvider"; +import { StoreProvider } from "../src/utils/testStore"; export const parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, @@ -30,12 +30,7 @@ export const decorators = [ (Story, options) => { const { parameters } = options; - let storeProviderProps = {}; - if (parameters.storeProviderProps != null) { - storeProviderProps = parameters.storeProviderProps; - } - - return + return ; }, diff --git a/README.md b/README.md index 617be089b..248d43d36 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,18 @@ Connections are a **declarative** way for components to get access to the data f layer that they need. This system is under development, and the current documentation about it is [available in Confluence](https://gfw.atlassian.net/wiki/spaces/TerraMatch/pages/1423147024/Connections) +Note for Storybook: Because multiple storybook components can be on the page at the same time that each +have their own copy of the redux store, the Connection utilities `loadConnection` (typically used +via `connectionLoaded` in `connectionShortcuts.ts`) and `connectionSelector` will not work as expected +in storybook stories. This is because those utilities rely on `ApiSlice.redux` and `ApiSlice.apiDataStore`, +and in the case of storybook, those will end up with only the redux store from the last component on the +page. Regular connection use through `useConnection` will work because it gets the store from the +Provider in the redux component tree in that case. + +When building storybook stories for components that rely on connections via `useConnection`, make sure +that the story is provided with a store that has all dependent data already loaded. See `testStore.tsx`'s +`buildStore` builder, and `Navbar.stories.tsx` for example usage. + ## Translation ([Transifex Native SDK](https://developers.transifex.com/docs/native)). Transifex native sdk provides a simple solution for internationalization. diff --git a/src/components/generic/Navbar/Navbar.stories.tsx b/src/components/generic/Navbar/Navbar.stories.tsx index 4e9dbaeb5..148b60ef1 100644 --- a/src/components/generic/Navbar/Navbar.stories.tsx +++ b/src/components/generic/Navbar/Navbar.stories.tsx @@ -1,6 +1,8 @@ import { Meta, StoryObj } from "@storybook/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { buildStore } from "@/utils/testStore"; + import Component from "./Navbar"; const meta: Meta = { @@ -14,7 +16,7 @@ const client = new QueryClient(); export const LoggedIn: Story = { parameters: { - storeProviderProps: { authToken: "fakeauthtoken" } + storeBuilder: buildStore().addLogin("fakeauthtoken") }, decorators: [ Story => ( diff --git a/src/connections/Organisation.ts b/src/connections/Organisation.ts index a4d7971e9..86fb42649 100644 --- a/src/connections/Organisation.ts +++ b/src/connections/Organisation.ts @@ -4,7 +4,7 @@ import { selectMe, useMyUser } from "@/connections/User"; import { OrganisationDto } from "@/generated/v3/userService/userServiceSchemas"; import { useConnection } from "@/hooks/useConnection"; import { ApiDataStore } from "@/store/apiSlice"; -import { Connection } from "@/types/connection"; +import { Connected, Connection } from "@/types/connection"; import { selectorCache } from "@/utils/selectorCache"; type OrganisationConnection = { @@ -54,8 +54,8 @@ const myOrganisationConnection: Connection = { // The "myOrganisationConnection" is only valid once the users/me response has been loaded, so // this hook depends on the myUserConnection to fetch users/me and then loads the data it needs // from the store. -export const useMyOrg = () => { +export const useMyOrg = (): Connected => { const [loaded] = useMyUser(); const [, orgShape] = useConnection(myOrganisationConnection); - return [loaded, orgShape]; + return loaded ? [true, orgShape] : [false, {}]; }; diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index a63d99d99..fa6a2ced8 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -83,7 +83,7 @@ export type ApiDataStore = ApiResources & { }; }; -const initialState = { +export const INITIAL_STATE = { ...RESOURCES.reduce((acc: Partial, resource) => { acc[resource] = {}; return acc; @@ -126,7 +126,7 @@ const isLogin = ({ url, method }: { url: string; method: Method }) => export const apiSlice = createSlice({ name: "api", - initialState, + initialState: INITIAL_STATE, reducers: { apiFetchStarting: (state, action: PayloadAction) => { diff --git a/src/utils/testStore.tsx b/src/utils/testStore.tsx new file mode 100644 index 000000000..553219819 --- /dev/null +++ b/src/utils/testStore.tsx @@ -0,0 +1,41 @@ +import { cloneDeep } from "lodash"; +import { HYDRATE } from "next-redux-wrapper"; +import { ReactNode, useMemo } from "react"; +import { Provider as ReduxProvider } from "react-redux"; + +import { INITIAL_STATE } from "@/store/apiSlice"; +import { makeStore } from "@/store/store"; + +class StoreBuilder { + store = cloneDeep(INITIAL_STATE); + + addLogin(token: string) { + this.store.logins[1] = { attributes: { token } }; + return this; + } +} + +export const StoreProvider = ({ storeBuilder, children }: { storeBuilder?: StoreBuilder; children: ReactNode }) => { + // We avoid using wrapper.useWrappedStore here so that different storybook components on the same page + // can have different instances of the redux store. This is a little wonky because anything that + // uses ApiSlice.store directly is going to get the last store created every time, including anything + // that uses connection loads or selectors from connectionShortcuts. However, storybook stories + // should be providing a store that has everything that component needs already loaded, and the + // components only use useConnection, so this will at least work for the expected normal case. + const store = useMemo( + () => { + const store = makeStore(); + const initialState = storeBuilder == null ? undefined : { api: storeBuilder.store }; + if (initialState != null) { + store.dispatch({ type: HYDRATE, payload: initialState }); + } + + return store; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + return {children}; +}; + +export const buildStore = () => new StoreBuilder(); From a1dc6d74c64f4537c671cbd16b840d4528043546 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Tue, 22 Oct 2024 11:19:48 -0700 Subject: [PATCH 068/102] [TM-1269] convert new uses of console to Log (cherry picked from commit 1e8d5ef0723d80a458163bd512bc656cfc5e4d4b) --- .../components/ResourceTabs/GalleryTab/GalleryTab.tsx | 3 ++- .../elements/Inputs/FileInput/FilePreviewTable.tsx | 5 +++-- src/components/elements/Map-mapbox/Map.tsx | 8 ++++---- .../elements/Map-mapbox/components/DashboardPopup.tsx | 3 ++- src/components/extensive/Modal/ModalAddImages.tsx | 3 ++- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/admin/components/ResourceTabs/GalleryTab/GalleryTab.tsx b/src/admin/components/ResourceTabs/GalleryTab/GalleryTab.tsx index 7e32e558d..2fbac0fec 100644 --- a/src/admin/components/ResourceTabs/GalleryTab/GalleryTab.tsx +++ b/src/admin/components/ResourceTabs/GalleryTab/GalleryTab.tsx @@ -14,6 +14,7 @@ import { useModalContext } from "@/context/modal.provider"; import { useDeleteV2FilesUUID, useGetV2MODELUUIDFiles } from "@/generated/apiComponents"; import { getCurrentPathEntity } from "@/helpers/entity"; import { EntityName, FileType } from "@/types/common"; +import Log from "@/utils/log"; interface IProps extends Omit { label?: string; @@ -98,7 +99,7 @@ const GalleryTab: FC = ({ label, entity, ...rest }) => { collection="media" entityData={ctx?.record} setErrorMessage={message => { - console.error(message); + Log.error(message); }} /> ); diff --git a/src/components/elements/Inputs/FileInput/FilePreviewTable.tsx b/src/components/elements/Inputs/FileInput/FilePreviewTable.tsx index 56f216d6d..37c5baa3e 100644 --- a/src/components/elements/Inputs/FileInput/FilePreviewTable.tsx +++ b/src/components/elements/Inputs/FileInput/FilePreviewTable.tsx @@ -11,6 +11,7 @@ import { useLoading } from "@/context/loaderAdmin.provider"; import { useModalContext } from "@/context/modal.provider"; import { usePatchV2MediaProjectProjectMediaUuid, usePatchV2MediaUuid } from "@/generated/apiComponents"; import { UploadedFile } from "@/types/common"; +import Log from "@/utils/log"; import Menu from "../../Menu/Menu"; import Table from "../../Table/Table"; @@ -89,7 +90,7 @@ const FilePreviewTable = ({ items, onDelete, updateFile, entityData }: FilePrevi }); } } catch (error) { - console.error("Error updating cover status:", error); + Log.error("Error updating cover status:", error); } finally { hideLoader(); } @@ -104,7 +105,7 @@ const FilePreviewTable = ({ items, onDelete, updateFile, entityData }: FilePrevi }); updateFile?.({ ...item, is_public: checked }); } catch (error) { - console.error("Error updating public status:", error); + Log.error("Error updating public status:", error); } finally { hideLoader(); } diff --git a/src/components/elements/Map-mapbox/Map.tsx b/src/components/elements/Map-mapbox/Map.tsx index 9fba75b48..c8606fe4f 100644 --- a/src/components/elements/Map-mapbox/Map.tsx +++ b/src/components/elements/Map-mapbox/Map.tsx @@ -4,8 +4,7 @@ import { useT } from "@transifex/react"; import _ from "lodash"; import mapboxgl, { LngLat } from "mapbox-gl"; import { useRouter } from "next/router"; -import React, { useEffect } from "react"; -import { DetailedHTMLProps, HTMLAttributes, useState } from "react"; +import React, { DetailedHTMLProps, HTMLAttributes, useEffect, useState } from "react"; import { When } from "react-if"; import { twMerge } from "tailwind-merge"; import { ValidationError } from "yup"; @@ -34,6 +33,7 @@ import { usePutV2TerrafundPolygonUuid } from "@/generated/apiComponents"; import { DashboardGetProjectsData, SitePolygonsDataResponse } from "@/generated/apiSchemas"; +import Log from "@/utils/log"; import { ImageGalleryItemData } from "../ImageGallery/ImageGalleryItem"; import { AdminPopup } from "./components/AdminPopup"; @@ -322,7 +322,7 @@ export const MapContainer = ({ }); if (!response) { - console.error("No response received from the server."); + Log.error("No response received from the server."); openNotification("error", t("Error!"), t("No response received from the server.")); return; } @@ -341,7 +341,7 @@ export const MapContainer = ({ hideLoader(); openNotification("success", t("Success!"), t("Image downloaded successfully")); } catch (error) { - console.error("Download error:", error); + Log.error("Download error:", error); hideLoader(); } }; diff --git a/src/components/elements/Map-mapbox/components/DashboardPopup.tsx b/src/components/elements/Map-mapbox/components/DashboardPopup.tsx index 138cafd9d..4ef481a3b 100644 --- a/src/components/elements/Map-mapbox/components/DashboardPopup.tsx +++ b/src/components/elements/Map-mapbox/components/DashboardPopup.tsx @@ -9,6 +9,7 @@ import { } from "@/generated/apiComponents"; import TooltipGridMap from "@/pages/dashboard/components/TooltipGridMap"; import { createQueryParams } from "@/utils/dashboardUtils"; +import Log from "@/utils/log"; const client = new QueryClient(); @@ -63,7 +64,7 @@ export const DashboardPopup = (event: any) => { setItems(parsedItems); addPopupToMap(); } else { - console.error("No data returned from the API"); + Log.error("No data returned from the API"); } } diff --git a/src/components/extensive/Modal/ModalAddImages.tsx b/src/components/extensive/Modal/ModalAddImages.tsx index 62b7a39f5..10aef1f13 100644 --- a/src/components/extensive/Modal/ModalAddImages.tsx +++ b/src/components/extensive/Modal/ModalAddImages.tsx @@ -15,6 +15,7 @@ import Status from "@/components/elements/Status/Status"; import Text from "@/components/elements/Text/Text"; import { useDeleteV2FilesUUID, usePostV2FileUploadMODELCOLLECTIONUUID } from "@/generated/apiComponents"; import { FileType, UploadedFile } from "@/types/common"; +import Log from "@/utils/log"; import Icon, { IconNames } from "../Icon/Icon"; import { ModalProps } from "./Modal"; @@ -211,7 +212,7 @@ const ModalAddImages: FC = ({ body.append("lng", location.longitude.toString()); } } catch (e) { - console.log(e); + Log.error(e); } uploadFile?.({ From fd81c927d3f7c12e6e86197cf5a167d0be3b0bb4 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Tue, 22 Oct 2024 12:06:22 -0700 Subject: [PATCH 069/102] [TM-1269] Regenerate API (cherry picked from commit d84bf6e82cf16eff12124b85022e07630e6dec03) --- .../v3/userService/userServiceComponents.ts | 84 +++++++++++++++++++ .../v3/userService/userServicePredicates.ts | 6 ++ 2 files changed, 90 insertions(+) diff --git a/src/generated/v3/userService/userServiceComponents.ts b/src/generated/v3/userService/userServiceComponents.ts index 6a60c063d..a25e014a0 100644 --- a/src/generated/v3/userService/userServiceComponents.ts +++ b/src/generated/v3/userService/userServiceComponents.ts @@ -152,3 +152,87 @@ export const usersFind = (variables: UsersFindVariables, signal?: AbortSignal) = ...variables, signal }); + +export type HealthControllerCheckError = Fetcher.ErrorWrapper<{ + status: 503; + payload: { + /** + * @example error + */ + status?: string; + /** + * @example {"database":{"status":"up"}} + */ + info?: { + [key: string]: { + status: string; + } & { + [key: string]: any; + }; + } | null; + /** + * @example {"redis":{"status":"down","message":"Could not connect"}} + */ + error?: { + [key: string]: { + status: string; + } & { + [key: string]: any; + }; + } | null; + /** + * @example {"database":{"status":"up"},"redis":{"status":"down","message":"Could not connect"}} + */ + details?: { + [key: string]: { + status: string; + } & { + [key: string]: any; + }; + }; + }; +}>; + +export type HealthControllerCheckResponse = { + /** + * @example ok + */ + status?: string; + /** + * @example {"database":{"status":"up"}} + */ + info?: { + [key: string]: { + status: string; + } & { + [key: string]: any; + }; + } | null; + /** + * @example {} + */ + error?: { + [key: string]: { + status: string; + } & { + [key: string]: any; + }; + } | null; + /** + * @example {"database":{"status":"up"}} + */ + details?: { + [key: string]: { + status: string; + } & { + [key: string]: any; + }; + }; +}; + +export const healthControllerCheck = (signal?: AbortSignal) => + userServiceFetch({ + url: "/health", + method: "get", + signal + }); diff --git a/src/generated/v3/userService/userServicePredicates.ts b/src/generated/v3/userService/userServicePredicates.ts index 16771c4b5..8c16e14db 100644 --- a/src/generated/v3/userService/userServicePredicates.ts +++ b/src/generated/v3/userService/userServicePredicates.ts @@ -13,3 +13,9 @@ export const usersFindIsFetching = (variables: UsersFindVariables) => (store: Ap export const usersFindFetchFailed = (variables: UsersFindVariables) => (store: ApiDataStore) => fetchFailed<{}, UsersFindPathParams>({ store, url: "/users/v3/users/{id}", method: "get", ...variables }); + +export const healthControllerCheckIsFetching = (store: ApiDataStore) => + isFetching<{}, {}>({ store, url: "/health", method: "get" }); + +export const healthControllerCheckFetchFailed = (store: ApiDataStore) => + fetchFailed<{}, {}>({ store, url: "/health", method: "get" }); From 2265868db751604fade6acb6b851cce4a75ad7e8 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Tue, 22 Oct 2024 13:34:27 -0700 Subject: [PATCH 070/102] [TM-1269] Fix build error --- src/admin/apiProvider/authProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/admin/apiProvider/authProvider.ts b/src/admin/apiProvider/authProvider.ts index c666ce58c..f7d5acb0d 100644 --- a/src/admin/apiProvider/authProvider.ts +++ b/src/admin/apiProvider/authProvider.ts @@ -30,7 +30,7 @@ export const authProvider: AuthProvider = { const { user } = await loadMyUser(); if (user == null) throw "No user logged in."; - return { id: user.uuid, fullName: user.fullName, primaryRole: user.primaryRole }; + return { id: user.uuid, fullName: user.fullName ?? undefined, primaryRole: user.primaryRole }; }, // get the user permissions (optional) From 21457ace4d698fc7175d992ba55b6482f61b7e49 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Tue, 22 Oct 2024 13:59:33 -0700 Subject: [PATCH 071/102] [TM-1269] Fix build error --- src/store/apiSlice.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index fa6a2ced8..ea5c9a13a 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -1,6 +1,6 @@ import { createListenerMiddleware, createSlice, PayloadAction } from "@reduxjs/toolkit"; import { WritableDraft } from "immer"; -import { isArray } from "lodash"; +import isArray from "lodash/isArray"; import { HYDRATE } from "next-redux-wrapper"; import { Store } from "redux"; From 44717073f44bf875d920f924db85606a3d757d16 Mon Sep 17 00:00:00 2001 From: cesarLima1 <105736261+cesarLima1@users.noreply.github.com> Date: Wed, 23 Oct 2024 15:53:22 -0400 Subject: [PATCH 072/102] [TM-1372] add metrics for total hectares under restoration and number of sites (#589) --- src/pages/dashboard/components/ContentOverview.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/pages/dashboard/components/ContentOverview.tsx b/src/pages/dashboard/components/ContentOverview.tsx index 3c5e3c5c1..605e106ee 100644 --- a/src/pages/dashboard/components/ContentOverview.tsx +++ b/src/pages/dashboard/components/ContentOverview.tsx @@ -29,11 +29,7 @@ import { TOTAL_HECTARES_UNDER_RESTORATION_TOOLTIP, TOTAL_NUMBER_OF_SITES_TOOLTIP } from "../constants/tooltips"; -import { - RESTORATION_STRATEGIES_REPRESENTED, - TOTAL_HECTARES_UNDER_RESTORATION, - TOTAL_NUMBER_OF_SITES -} from "../mockedData/dashboard"; +import { RESTORATION_STRATEGIES_REPRESENTED } from "../mockedData/dashboard"; import SecDashboard from "./SecDashboard"; import TooltipGridMap from "./TooltipGridMap"; @@ -182,13 +178,13 @@ const ContentOverview = (props: ContentOverviewProps) => {
Date: Wed, 23 Oct 2024 16:11:01 -0400 Subject: [PATCH 073/102] [TM-1413] add null safety when calling layers (#590) --- src/components/elements/Map-mapbox/utils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/elements/Map-mapbox/utils.ts b/src/components/elements/Map-mapbox/utils.ts index 7cb40b89e..a0bfaa843 100644 --- a/src/components/elements/Map-mapbox/utils.ts +++ b/src/components/elements/Map-mapbox/utils.ts @@ -426,6 +426,9 @@ export const addPopupToLayer = ( targetLayers = [targetLayers[0]]; } targetLayers.forEach(targetLayer => { + if (!targetLayer?.id || !map.getLayer(targetLayer.id)) { + return; + } map.on("click", targetLayer.id, (e: any) => { const currentMode = draw?.getMode(); if (currentMode === "draw_polygon" || currentMode === "draw_line_string") return; From 8ddcb430896758e37d1dd983f7f26713b56740bc Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 23 Oct 2024 15:51:47 -0700 Subject: [PATCH 074/102] [TM-1269] Regenerate API integration with health controller ignored. --- .../v3/userService/userServiceComponents.ts | 84 ------------------- .../v3/userService/userServicePredicates.ts | 6 -- 2 files changed, 90 deletions(-) diff --git a/src/generated/v3/userService/userServiceComponents.ts b/src/generated/v3/userService/userServiceComponents.ts index a25e014a0..6a60c063d 100644 --- a/src/generated/v3/userService/userServiceComponents.ts +++ b/src/generated/v3/userService/userServiceComponents.ts @@ -152,87 +152,3 @@ export const usersFind = (variables: UsersFindVariables, signal?: AbortSignal) = ...variables, signal }); - -export type HealthControllerCheckError = Fetcher.ErrorWrapper<{ - status: 503; - payload: { - /** - * @example error - */ - status?: string; - /** - * @example {"database":{"status":"up"}} - */ - info?: { - [key: string]: { - status: string; - } & { - [key: string]: any; - }; - } | null; - /** - * @example {"redis":{"status":"down","message":"Could not connect"}} - */ - error?: { - [key: string]: { - status: string; - } & { - [key: string]: any; - }; - } | null; - /** - * @example {"database":{"status":"up"},"redis":{"status":"down","message":"Could not connect"}} - */ - details?: { - [key: string]: { - status: string; - } & { - [key: string]: any; - }; - }; - }; -}>; - -export type HealthControllerCheckResponse = { - /** - * @example ok - */ - status?: string; - /** - * @example {"database":{"status":"up"}} - */ - info?: { - [key: string]: { - status: string; - } & { - [key: string]: any; - }; - } | null; - /** - * @example {} - */ - error?: { - [key: string]: { - status: string; - } & { - [key: string]: any; - }; - } | null; - /** - * @example {"database":{"status":"up"}} - */ - details?: { - [key: string]: { - status: string; - } & { - [key: string]: any; - }; - }; -}; - -export const healthControllerCheck = (signal?: AbortSignal) => - userServiceFetch({ - url: "/health", - method: "get", - signal - }); diff --git a/src/generated/v3/userService/userServicePredicates.ts b/src/generated/v3/userService/userServicePredicates.ts index 8c16e14db..16771c4b5 100644 --- a/src/generated/v3/userService/userServicePredicates.ts +++ b/src/generated/v3/userService/userServicePredicates.ts @@ -13,9 +13,3 @@ export const usersFindIsFetching = (variables: UsersFindVariables) => (store: Ap export const usersFindFetchFailed = (variables: UsersFindVariables) => (store: ApiDataStore) => fetchFailed<{}, UsersFindPathParams>({ store, url: "/users/v3/users/{id}", method: "get", ...variables }); - -export const healthControllerCheckIsFetching = (store: ApiDataStore) => - isFetching<{}, {}>({ store, url: "/health", method: "get" }); - -export const healthControllerCheckFetchFailed = (store: ApiDataStore) => - fetchFailed<{}, {}>({ store, url: "/health", method: "get" }); From ab8e1d574116d792803ba2836b99aaba01f78ed5 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 23 Oct 2024 15:54:20 -0700 Subject: [PATCH 075/102] [TM-1354] Generate delayed jobs API integration. --- openapi-codegen.config.ts | 2 +- package.json | 3 +- .../v3/jobService/jobServiceComponents.ts | 78 +++++++++++++++ .../v3/jobService/jobServiceFetcher.ts | 96 +++++++++++++++++++ .../v3/jobService/jobServicePredicates.ts | 14 +++ .../v3/jobService/jobServiceSchemas.ts | 19 ++++ 6 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 src/generated/v3/jobService/jobServiceComponents.ts create mode 100644 src/generated/v3/jobService/jobServiceFetcher.ts create mode 100644 src/generated/v3/jobService/jobServicePredicates.ts create mode 100644 src/generated/v3/jobService/jobServiceSchemas.ts diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 2d97a2d6a..26baaa3ea 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -24,7 +24,7 @@ dotenv.config(); // are namespaced by feature set rather than service (a service may contain multiple namespaces), we // isolate the generated API integration by service to make it easier for a developer to find where // the associated BE code is for a given FE API integration. -const SERVICES = ["user-service"]; +const SERVICES = ["user-service", "job-service"]; const config: Record = { api: { diff --git a/package.json b/package.json index a61cc42ed..f70c2095e 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "build-storybook": "storybook build", "generate:api": "openapi-codegen gen api", "generate:userService": "openapi-codegen gen userService", - "generate:services": "npm run generate:userService", + "generate:jobService": "openapi-codegen gen jobService", + "generate:services": "npm run generate:userService && npm run generate:jobService", "tx:push": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli push --key-generator=hash src/ --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET", "tx:pull": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli pull --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET" }, diff --git a/src/generated/v3/jobService/jobServiceComponents.ts b/src/generated/v3/jobService/jobServiceComponents.ts new file mode 100644 index 000000000..5329fbc8a --- /dev/null +++ b/src/generated/v3/jobService/jobServiceComponents.ts @@ -0,0 +1,78 @@ +/** + * Generated by @openapi-codegen + * + * @version 1.0 + */ +import type * as Fetcher from "./jobServiceFetcher"; +import { jobServiceFetch } from "./jobServiceFetcher"; +import type * as Schemas from "./jobServiceSchemas"; + +export type DelayedJobsFindPathParams = { + uuid: string; +}; + +export type DelayedJobsFindError = Fetcher.ErrorWrapper< + | { + status: 401; + payload: { + /** + * @example 401 + */ + statusCode: number; + /** + * @example Unauthorized + */ + message: string; + /** + * @example Unauthorized + */ + error?: string; + }; + } + | { + status: 404; + payload: { + /** + * @example 404 + */ + statusCode: number; + /** + * @example Not Found + */ + message: string; + /** + * @example Not Found + */ + error?: string; + }; + } +>; + +export type DelayedJobsFindResponse = { + data?: { + /** + * @example delayedJobs + */ + type?: string; + /** + * @format uuid + */ + id?: string; + attributes?: Schemas.DelayedJobDto; + }; +}; + +export type DelayedJobsFindVariables = { + pathParams: DelayedJobsFindPathParams; +}; + +/** + * Get the current status and potentially payload or error from a delayed job. + */ +export const delayedJobsFind = (variables: DelayedJobsFindVariables, signal?: AbortSignal) => + jobServiceFetch({ + url: "/jobs/v3/delayedJobs/{uuid}", + method: "get", + ...variables, + signal + }); diff --git a/src/generated/v3/jobService/jobServiceFetcher.ts b/src/generated/v3/jobService/jobServiceFetcher.ts new file mode 100644 index 000000000..83ccd35dd --- /dev/null +++ b/src/generated/v3/jobService/jobServiceFetcher.ts @@ -0,0 +1,96 @@ +export type JobServiceFetcherExtraProps = { + /** + * You can add some extra props to your generated fetchers. + * + * Note: You need to re-gen after adding the first property to + * have the `JobServiceFetcherExtraProps` injected in `JobServiceComponents.ts` + **/ +}; + +const baseUrl = ""; // TODO add your baseUrl + +export type ErrorWrapper = TError | { status: "unknown"; payload: string }; + +export type JobServiceFetcherOptions = { + url: string; + method: string; + body?: TBody; + headers?: THeaders; + queryParams?: TQueryParams; + pathParams?: TPathParams; + signal?: AbortSignal; +} & JobServiceFetcherExtraProps; + +export async function jobServiceFetch< + TData, + TError, + TBody extends {} | FormData | undefined | null, + THeaders extends {}, + TQueryParams extends {}, + TPathParams extends {} +>({ + url, + method, + body, + headers, + pathParams, + queryParams, + signal +}: JobServiceFetcherOptions): Promise { + try { + const requestHeaders: HeadersInit = { + "Content-Type": "application/json", + ...headers + }; + + /** + * As the fetch API is being used, when multipart/form-data is specified + * the Content-Type header must be deleted so that the browser can set + * the correct boundary. + * https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object + */ + if (requestHeaders["Content-Type"].toLowerCase().includes("multipart/form-data")) { + delete requestHeaders["Content-Type"]; + } + + const response = await window.fetch(`${baseUrl}${resolveUrl(url, queryParams, pathParams)}`, { + signal, + method: method.toUpperCase(), + body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined, + headers: requestHeaders + }); + if (!response.ok) { + let error: ErrorWrapper; + try { + error = await response.json(); + } catch (e) { + error = { + status: "unknown" as const, + payload: e instanceof Error ? `Unexpected error (${e.message})` : "Unexpected error" + }; + } + + throw error; + } + + if (response.headers.get("content-type")?.includes("json")) { + return await response.json(); + } else { + // if it is not a json response, assume it is a blob and cast it to TData + return (await response.blob()) as unknown as TData; + } + } catch (e) { + let errorObject: Error = { + name: "unknown" as const, + message: e instanceof Error ? `Network error (${e.message})` : "Network error", + stack: e as string + }; + throw errorObject; + } +} + +const resolveUrl = (url: string, queryParams: Record = {}, pathParams: Record = {}) => { + let query = new URLSearchParams(queryParams).toString(); + if (query) query = `?${query}`; + return url.replace(/\{\w*\}/g, key => pathParams[key.slice(1, -1)]) + query; +}; diff --git a/src/generated/v3/jobService/jobServicePredicates.ts b/src/generated/v3/jobService/jobServicePredicates.ts new file mode 100644 index 000000000..348490ef9 --- /dev/null +++ b/src/generated/v3/jobService/jobServicePredicates.ts @@ -0,0 +1,14 @@ +import { isFetching, fetchFailed } from "../utils"; +import { ApiDataStore } from "@/store/apiSlice"; +import { DelayedJobsFindPathParams, DelayedJobsFindVariables } from "./jobServiceComponents"; + +export const delayedJobsFindIsFetching = (variables: DelayedJobsFindVariables) => (store: ApiDataStore) => + isFetching<{}, DelayedJobsFindPathParams>({ store, url: "/jobs/v3/delayedJobs/{uuid}", method: "get", ...variables }); + +export const delayedJobsFindFetchFailed = (variables: DelayedJobsFindVariables) => (store: ApiDataStore) => + fetchFailed<{}, DelayedJobsFindPathParams>({ + store, + url: "/jobs/v3/delayedJobs/{uuid}", + method: "get", + ...variables + }); diff --git a/src/generated/v3/jobService/jobServiceSchemas.ts b/src/generated/v3/jobService/jobServiceSchemas.ts new file mode 100644 index 000000000..179ba4d2a --- /dev/null +++ b/src/generated/v3/jobService/jobServiceSchemas.ts @@ -0,0 +1,19 @@ +/** + * Generated by @openapi-codegen + * + * @version 1.0 + */ +export type DelayedJobDto = { + /** + * The current status of the job. If the status is not pending, the payload and statusCode will be provided. + */ + status: "pending" | "failed" | "succeeded"; + /** + * If the job is out of pending state, this is the HTTP status code for the completed process + */ + statusCode: number | null; + /** + * If the job is out of pending state, this is the JSON payload for the completed process + */ + payload: string | null; +}; From 6aa5307bc4a7d38a19999faa84db1c31776db1c7 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 23 Oct 2024 16:28:57 -0700 Subject: [PATCH 076/102] [TM-1354] Implement connection and hook for fetching delayed jobs. --- README.md | 2 +- src/connections/DelayedJob.ts | 69 ++++++++++++ src/connections/User.ts | 2 +- .../v3/jobService/jobServiceFetcher.ts | 100 +----------------- src/store/apiSlice.ts | 4 +- 5 files changed, 79 insertions(+), 98 deletions(-) create mode 100644 src/connections/DelayedJob.ts diff --git a/README.md b/README.md index 248d43d36..1651703e1 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ When adding a new **resource** to the v3 API, a couple of steps are needed to in `RESOURCES` const. This will make sure it's listed in the type of the ApiStore so that resources that match that type are seamlessly folded into the store cache structure. * The shape of the resource should be specified by the auto-generated API. This type needs to be added to the `ApiResource` type in `apiSlice.ts`. This allows us to have strongly typed results - coming from the redux APi store. + coming from the redux API store. ### Connections Connections are a **declarative** way for components to get access to the data from the cached API diff --git a/src/connections/DelayedJob.ts b/src/connections/DelayedJob.ts new file mode 100644 index 000000000..8c624c79b --- /dev/null +++ b/src/connections/DelayedJob.ts @@ -0,0 +1,69 @@ +import { createSelector } from "reselect"; + +import { delayedJobsFind } from "@/generated/v3/jobService/jobServiceComponents"; +import { delayedJobsFindFetchFailed, delayedJobsFindIsFetching } from "@/generated/v3/jobService/jobServicePredicates"; +import { DelayedJobDto } from "@/generated/v3/jobService/jobServiceSchemas"; +import { useConnection } from "@/hooks/useConnection"; +import { useValueChanged } from "@/hooks/useValueChanged"; +import { ApiDataStore } from "@/store/apiSlice"; +import { Connected, Connection } from "@/types/connection"; +import { selectorCache } from "@/utils/selectorCache"; + +type DelayedJobConnection = { + job?: DelayedJobDto; + findingJob: boolean; + jobFindFailed: boolean; +}; + +type DelayedJobConnectionProps = { + delayedJobId?: string; +}; + +const delayedJobSelector = (delayedJobId?: string) => (store: ApiDataStore) => + delayedJobId == null ? undefined : store.delayedJobs?.[delayedJobId]; +const findDelayedJobProps = (delayedJobId?: string) => ({ pathParams: { uuid: delayedJobId ?? "missingId" } }); +const findDelayedJob = (delayedJobId: string) => delayedJobsFind(findDelayedJobProps(delayedJobId)); + +const delayedJobConnection: Connection = { + load: ({ jobFindFailed, job }, { delayedJobId }) => { + if (delayedJobId != null && job == null && !jobFindFailed) findDelayedJob(delayedJobId); + }, + + isLoaded: ({ jobFindFailed, job }, { delayedJobId }) => delayedJobId == null || job != null || jobFindFailed, + + selector: selectorCache( + ({ delayedJobId }) => delayedJobId ?? "", + ({ delayedJobId }) => + createSelector( + [ + delayedJobSelector(delayedJobId), + delayedJobsFindIsFetching(findDelayedJobProps(delayedJobId)), + delayedJobsFindFetchFailed(findDelayedJobProps(delayedJobId)) + ], + (job, jobLoading, jobLoadFailure) => ({ + job: job?.attributes, + findingJob: jobLoading, + jobFindFailed: jobLoadFailure != null + }) + ) + ) +}; + +const JOB_POLL_TIMEOUT = 300; // in ms +export const useDelayedJobResult = (delayedJobId?: string): Connected => { + const [loaded, jobResult] = useConnection(delayedJobConnection); + useValueChanged(jobResult?.job, () => { + if (delayedJobId != null && jobResult?.job?.status === "pending") { + // If we received an updated job and the status is pending, ask the server again after the + // defined timeout + setTimeout(() => { + findDelayedJob(delayedJobId); + }, JOB_POLL_TIMEOUT); + } + }); + + // Don't claim to our parent component that we're done loading until the connection is loaded + // and either there is no job (there's a failure instead) or the job status is not pending. + const status = jobResult?.job?.status; + return loaded && status !== "pending" ? [true, jobResult] : [false, {}]; +}; diff --git a/src/connections/User.ts b/src/connections/User.ts index 36f65f448..5fe2b9a80 100644 --- a/src/connections/User.ts +++ b/src/connections/User.ts @@ -24,7 +24,7 @@ export const selectMe = createSelector([selectMeId, selectUsers], (meId, users) const FIND_ME: UsersFindVariables = { pathParams: { id: "me" } }; -export const myUserConnection: Connection = { +const myUserConnection: Connection = { load: ({ isLoggedIn, user }) => { if (user == null && isLoggedIn) usersFind(FIND_ME); }, diff --git a/src/generated/v3/jobService/jobServiceFetcher.ts b/src/generated/v3/jobService/jobServiceFetcher.ts index 83ccd35dd..97ea202d3 100644 --- a/src/generated/v3/jobService/jobServiceFetcher.ts +++ b/src/generated/v3/jobService/jobServiceFetcher.ts @@ -1,96 +1,6 @@ -export type JobServiceFetcherExtraProps = { - /** - * You can add some extra props to your generated fetchers. - * - * Note: You need to re-gen after adding the first property to - * have the `JobServiceFetcherExtraProps` injected in `JobServiceComponents.ts` - **/ -}; +// This type is imported in the auto generated `jobServiceComponents` file, so it needs to be +// exported from this file. +export type { ErrorWrapper } from "../utils"; -const baseUrl = ""; // TODO add your baseUrl - -export type ErrorWrapper = TError | { status: "unknown"; payload: string }; - -export type JobServiceFetcherOptions = { - url: string; - method: string; - body?: TBody; - headers?: THeaders; - queryParams?: TQueryParams; - pathParams?: TPathParams; - signal?: AbortSignal; -} & JobServiceFetcherExtraProps; - -export async function jobServiceFetch< - TData, - TError, - TBody extends {} | FormData | undefined | null, - THeaders extends {}, - TQueryParams extends {}, - TPathParams extends {} ->({ - url, - method, - body, - headers, - pathParams, - queryParams, - signal -}: JobServiceFetcherOptions): Promise { - try { - const requestHeaders: HeadersInit = { - "Content-Type": "application/json", - ...headers - }; - - /** - * As the fetch API is being used, when multipart/form-data is specified - * the Content-Type header must be deleted so that the browser can set - * the correct boundary. - * https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object - */ - if (requestHeaders["Content-Type"].toLowerCase().includes("multipart/form-data")) { - delete requestHeaders["Content-Type"]; - } - - const response = await window.fetch(`${baseUrl}${resolveUrl(url, queryParams, pathParams)}`, { - signal, - method: method.toUpperCase(), - body: body ? (body instanceof FormData ? body : JSON.stringify(body)) : undefined, - headers: requestHeaders - }); - if (!response.ok) { - let error: ErrorWrapper; - try { - error = await response.json(); - } catch (e) { - error = { - status: "unknown" as const, - payload: e instanceof Error ? `Unexpected error (${e.message})` : "Unexpected error" - }; - } - - throw error; - } - - if (response.headers.get("content-type")?.includes("json")) { - return await response.json(); - } else { - // if it is not a json response, assume it is a blob and cast it to TData - return (await response.blob()) as unknown as TData; - } - } catch (e) { - let errorObject: Error = { - name: "unknown" as const, - message: e instanceof Error ? `Network error (${e.message})` : "Network error", - stack: e as string - }; - throw errorObject; - } -} - -const resolveUrl = (url: string, queryParams: Record = {}, pathParams: Record = {}) => { - let query = new URLSearchParams(queryParams).toString(); - if (query) query = `?${query}`; - return url.replace(/\{\w*\}/g, key => pathParams[key.slice(1, -1)]) + query; -}; +// The serviceFetch method is the shared fetch method for all service fetchers. +export { serviceFetch as jobServiceFetch } from "../utils"; diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index ea5c9a13a..8023d2872 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -5,6 +5,7 @@ import { HYDRATE } from "next-redux-wrapper"; import { Store } from "redux"; import { setAccessToken } from "@/admin/apiProvider/utils/token"; +import { DelayedJobDto } from "@/generated/v3/jobService/jobServiceSchemas"; import { LoginDto, OrganisationDto, UserDto } from "@/generated/v3/userService/userServiceSchemas"; export type PendingErrorState = { @@ -53,9 +54,10 @@ type StoreResourceMap = Record; logins: StoreResourceMap; organisations: StoreResourceMap; users: StoreResourceMap; From 0ccc0e77382e769f7432addf972551dd32cbfdc1 Mon Sep 17 00:00:00 2001 From: cesarLima1 <105736261+cesarLima1@users.noreply.github.com> Date: Thu, 24 Oct 2024 11:08:47 -0400 Subject: [PATCH 077/102] [TM-1373] restoration strategies chart (#591) * [TM-1373] add chart for restoration strategies * [TM-1373] change restoration strategies chart style * [TM-1373] add contant for maxlineLength and remove log * [TM-1373] add maxvalue --- src/constants/dashboardConsts.ts | 3 +- .../charts/CustomLabelRestoration.tsx | 22 ++++++++ .../charts/CustomXAxisTickRestoration.tsx | 41 ++++++++++++++ src/pages/dashboard/charts/SimpleBarChart.tsx | 55 +++++++++++++++++++ .../dashboard/components/ContentOverview.tsx | 5 +- .../dashboard/components/SecDashboard.tsx | 4 ++ src/utils/dashboardUtils.ts | 21 ++++++- 7 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 src/pages/dashboard/charts/CustomLabelRestoration.tsx create mode 100644 src/pages/dashboard/charts/CustomXAxisTickRestoration.tsx create mode 100644 src/pages/dashboard/charts/SimpleBarChart.tsx diff --git a/src/constants/dashboardConsts.ts b/src/constants/dashboardConsts.ts index b27cd05ba..dd534a3ca 100644 --- a/src/constants/dashboardConsts.ts +++ b/src/constants/dashboardConsts.ts @@ -3,7 +3,8 @@ export const CHART_TYPES = { treesPlantedBarChart: "treesPlantedBarChart", groupedBarChart: "groupedBarChart", doughnutChart: "doughnutChart", - barChart: "barChart" + barChart: "barChart", + simpleBarChart: "simpleBarChart" }; export const JOBS_CREATED_CHART_TYPE = { diff --git a/src/pages/dashboard/charts/CustomLabelRestoration.tsx b/src/pages/dashboard/charts/CustomLabelRestoration.tsx new file mode 100644 index 000000000..1247cf210 --- /dev/null +++ b/src/pages/dashboard/charts/CustomLabelRestoration.tsx @@ -0,0 +1,22 @@ +import React from "react"; + +export const CustomLabel: React.FC = props => { + const { x, y, width, height, value } = props; + const textHeight = 16; + const padding = 4; + const isSmallBar = height < textHeight + padding; + return ( + + {`${value.toFixed(0)} ha`} + + ); +}; + +export default CustomLabel; diff --git a/src/pages/dashboard/charts/CustomXAxisTickRestoration.tsx b/src/pages/dashboard/charts/CustomXAxisTickRestoration.tsx new file mode 100644 index 000000000..c2d8e45af --- /dev/null +++ b/src/pages/dashboard/charts/CustomXAxisTickRestoration.tsx @@ -0,0 +1,41 @@ +import React from "react"; + +export const CustomXAxisTick: React.FC = props => { + const { x, y, payload } = props; + const words = payload.value.split(" "); + const lineHeight = 16; + const topPadding = 20; + const maxLineLength = 16; + let lines: string[] = []; + let currentLine = words[0]; + for (let i = 1; i < words.length; i++) { + const word = words[i]; + const testLine = `${currentLine} ${word}`; + if (testLine.length > maxLineLength) { + lines.push(currentLine); + currentLine = word; + } else { + currentLine = testLine; + } + } + lines.push(currentLine); + return ( + + {lines.map((line, index) => ( + + {line} + + ))} + + ); +}; + +export default CustomXAxisTick; diff --git a/src/pages/dashboard/charts/SimpleBarChart.tsx b/src/pages/dashboard/charts/SimpleBarChart.tsx new file mode 100644 index 000000000..df726b6dd --- /dev/null +++ b/src/pages/dashboard/charts/SimpleBarChart.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; + +import { getBarColorRestoration } from "@/utils/dashboardUtils"; + +import { CustomBar } from "./CustomBarJobsCreated"; +import CustomLabel from "./CustomLabelRestoration"; +import CustomTooltip from "./CustomTooltip"; +import CustomXAxisTick from "./CustomXAxisTickRestoration"; + +type ResturationStrategy = { + label: string; + value: number; +}; + +type FormattedData = { + name: string; + value: number; +}; + +const SimpleBarChart = ({ data }: { data: ResturationStrategy[] }) => { + const formattedData: FormattedData[] = data.map(item => ({ + name: item.label.split(",").join(" + ").replace(/-/g, " "), + value: item.value + })); + + return ( +
+ + + + } /> + + } shape={(props: any) => }> + {formattedData.map((entry, index) => ( + + ))} + + } cursor={{ fill: "rgba(0, 0, 0, 0.05)" }} /> + + +
+ ); +}; + +export default SimpleBarChart; diff --git a/src/pages/dashboard/components/ContentOverview.tsx b/src/pages/dashboard/components/ContentOverview.tsx index 605e106ee..4026d9716 100644 --- a/src/pages/dashboard/components/ContentOverview.tsx +++ b/src/pages/dashboard/components/ContentOverview.tsx @@ -29,7 +29,6 @@ import { TOTAL_HECTARES_UNDER_RESTORATION_TOOLTIP, TOTAL_NUMBER_OF_SITES_TOOLTIP } from "../constants/tooltips"; -import { RESTORATION_STRATEGIES_REPRESENTED } from "../mockedData/dashboard"; import SecDashboard from "./SecDashboard"; import TooltipGridMap from "./TooltipGridMap"; @@ -192,7 +191,9 @@ const ContentOverview = (props: ContentOverviewProps) => {
+ + + {data?.graphic}
diff --git a/src/utils/dashboardUtils.ts b/src/utils/dashboardUtils.ts index 61df21c13..63a9e588b 100644 --- a/src/utils/dashboardUtils.ts +++ b/src/utils/dashboardUtils.ts @@ -231,6 +231,12 @@ export const formatLabelsVolunteers = (value: string): string => { export const COLORS_VOLUNTEERS = ["#7BBD31", "#27A9E0"]; +export const getBarColorRestoration = (name: string) => { + if (name.includes("Tree Planting")) return "#7BBD31"; + if (name.includes("direct seeding")) return "#27A9E0"; + return "#13487A"; +}; + export const getPercentageVolunteers = (value: number, total: number): string => { return ((value / total) * 100).toFixed(1); }; @@ -254,6 +260,12 @@ const landUseTypeOptions: Option[] = [ { title: "Open Natural Ecosystem", value: "open-natural-ecosystem" } ]; +const getRestorationStrategyOptions = { + "tree-planting": "Tree Planting", + "direct-seeding": "Direct Seeding", + "assisted-natural-regeneration": "Assisted Natural Regeneration" +}; + export const parseHectaresUnderRestorationData = ( totalSectionHeader: TotalSectionHeader, dashboardVolunteersSurvivalRate: DashboardVolunteersSurvivalRate, @@ -289,8 +301,13 @@ export const parseHectaresUnderRestorationData = ( const option = landUseTypeOptions.find(opt => opt.value === value); return option ? option.title : value; }; - - const restorationStrategiesRepresented = objectToArray(hectaresUnderRestoration?.restoration_strategies_represented); + console.log(hectaresUnderRestoration); + const restorationStrategiesRepresented = objectToArray( + hectaresUnderRestoration?.restoration_strategies_represented + ).map(item => ({ + label: getRestorationStrategyOptions[item.label as keyof typeof getRestorationStrategyOptions] ?? item.label, + value: item.value + })); const graphicTargetLandUseTypes = objectToArray(hectaresUnderRestoration?.target_land_use_types_represented).map( item => ({ From ab69b53fc61cefda1b35a8f96a78080f49fbc84d Mon Sep 17 00:00:00 2001 From: cesarLima1 <105736261+cesarLima1@users.noreply.github.com> Date: Thu, 24 Oct 2024 17:06:25 -0400 Subject: [PATCH 078/102] [TM-1407] project profile dashboard (#592) * [TM-1369] implement jobs created data and util functions * [TM-1370] add grouped bar chart for jobs created * [TM-1371] doughnut chart for volunteerss section * [TM-1370] add missing default export for components * [TM-1371] add missing default export * [TM-1407] add project uuid to filters * [TM-1407] add general data for projects * [TM-1407] remove unused const --- .../Map-mapbox/components/DashboardPopup.tsx | 3 +- src/constants/dashboardConsts.ts | 5 + src/context/dashboard.provider.tsx | 16 ++- src/pages/dashboard/[id].page.tsx | 47 +-------- .../dashboard/components/ContentOverview.tsx | 6 +- .../dashboard/components/HeaderDashboard.tsx | 14 ++- src/pages/dashboard/hooks/useDashboardData.ts | 10 +- src/pages/dashboard/index.page.tsx | 97 ++++++++++++++++--- src/pages/dashboard/project/index.page.tsx | 3 +- src/utils/dashboardUtils.ts | 34 ++++++- 10 files changed, 160 insertions(+), 75 deletions(-) diff --git a/src/components/elements/Map-mapbox/components/DashboardPopup.tsx b/src/components/elements/Map-mapbox/components/DashboardPopup.tsx index 138cafd9d..973cfc4ac 100644 --- a/src/components/elements/Map-mapbox/components/DashboardPopup.tsx +++ b/src/components/elements/Map-mapbox/components/DashboardPopup.tsx @@ -33,7 +33,8 @@ export const DashboardPopup = (event: any) => { programmes: [], country: isoCountry, "organisations.type": [], - landscapes: [] + landscapes: [], + uuid: "" }; const queryParams: any = createQueryParams(parsedFilters); const response: any = await fetchGetV2DashboardTotalSectionHeaderCountry({ queryParams }); diff --git a/src/constants/dashboardConsts.ts b/src/constants/dashboardConsts.ts index dd534a3ca..9770e95e1 100644 --- a/src/constants/dashboardConsts.ts +++ b/src/constants/dashboardConsts.ts @@ -32,3 +32,8 @@ export const MONTHS = [ "November", "December" ]; + +export const ORGANIZATIONS_TYPES = { + "non-profit-organization": "Non-Profit", + "for-profit-organization": "Enterprise" +}; diff --git a/src/context/dashboard.provider.tsx b/src/context/dashboard.provider.tsx index c9945d184..245b88447 100644 --- a/src/context/dashboard.provider.tsx +++ b/src/context/dashboard.provider.tsx @@ -14,6 +14,7 @@ type DashboardType = { landscapes: string[]; country: CountriesProps; organizations: string[]; + uuid: string; }; setFilters: React.Dispatch< React.SetStateAction<{ @@ -21,10 +22,13 @@ type DashboardType = { landscapes: string[]; country: { country_slug: string; id: number; data: any }; organizations: string[]; + uuid: string; }> >; searchTerm: string; setSearchTerm: React.Dispatch>; + frameworks: { framework_slug?: string; name?: string }[]; + setFrameworks: React.Dispatch>; }; const defaultValues: DashboardType = { filters: { @@ -38,22 +42,28 @@ const defaultValues: DashboardType = { icon: "" } }, - organizations: [] + organizations: [], + uuid: "" }, setFilters: () => {}, searchTerm: "", - setSearchTerm: () => {} + setSearchTerm: () => {}, + frameworks: [], + setFrameworks: () => {} }; const DashboardContext = createContext(defaultValues); export const DashboardProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [filters, setFilters] = React.useState(defaultValues.filters); const [searchTerm, setSearchTerm] = React.useState(""); + const [frameworks, setFrameworks] = React.useState<{ framework_slug?: string; name?: string }[]>([]); const contextValue: DashboardType = { filters, setFilters, searchTerm, - setSearchTerm + setSearchTerm, + frameworks, + setFrameworks }; return {children}; }; diff --git a/src/pages/dashboard/[id].page.tsx b/src/pages/dashboard/[id].page.tsx index 3206848fc..91534b9e7 100644 --- a/src/pages/dashboard/[id].page.tsx +++ b/src/pages/dashboard/[id].page.tsx @@ -10,10 +10,8 @@ import PageCard from "@/components/extensive/PageElements/Card/PageCard"; import PageRow from "@/components/extensive/PageElements/Row/PageRow"; import { CountriesProps } from "@/components/generic/Layout/DashboardLayout"; -import ContentOverview from "./components/ContentOverview"; import SecDashboard from "./components/SecDashboard"; import { - ACTIVE_PROJECTS_TOOLTIP, HECTARES_UNDER_RESTORATION_TOOLTIP, JOBS_CREATED_BY_AGE_TOOLTIP, JOBS_CREATED_BY_GENDER_TOOLTIP, @@ -31,7 +29,6 @@ import { VOLUNTEERS_CREATED_BY_GENDER_TOOLTIP } from "./constants/tooltips"; import { - DATA_ACTIVE_COUNTRY, JOBS_CREATED_BY_AGE, JOBS_CREATED_BY_GENDER, LABEL_LEGEND, @@ -71,46 +68,6 @@ const Country: React.FC = ({ selectedCountry }) => { } ]; - const COLUMN_ACTIVE_COUNTRY = [ - { - header: "Project", - accessorKey: "project", - enableSorting: false - }, - { - header: "Trees Planted", - accessorKey: "treesPlanted", - enableSorting: false - }, - { - header: "Hectares", - accessorKey: "restoratioHectares", - enableSorting: false - }, - { - header: "Jobs Created", - accessorKey: "jobsCreated", - enableSorting: false - }, - { - header: "Volunteers", - accessorKey: "volunteers", - enableSorting: false - }, - { - header: "", - accessorKey: "link", - enableSorting: false, - cell: () => { - return ( - - - - ); - } - } - ]; - return (
@@ -256,7 +213,7 @@ const Country: React.FC = ({ selectedCountry }) => {
- = ({ selectedCountry }) => { columns={COLUMN_ACTIVE_COUNTRY} titleTable={t("ACTIVE PROJECTS")} textTooltipTable={t(ACTIVE_PROJECTS_TOOLTIP)} - /> + /> */}
); }; diff --git a/src/pages/dashboard/components/ContentOverview.tsx b/src/pages/dashboard/components/ContentOverview.tsx index 4026d9716..316d52113 100644 --- a/src/pages/dashboard/components/ContentOverview.tsx +++ b/src/pages/dashboard/components/ContentOverview.tsx @@ -1,4 +1,4 @@ -import { ColumnDef, RowData } from "@tanstack/react-table"; +import { ColumnDef } from "@tanstack/react-table"; import { useT } from "@transifex/react"; import React from "react"; @@ -32,6 +32,10 @@ import { import SecDashboard from "./SecDashboard"; import TooltipGridMap from "./TooltipGridMap"; +interface RowData { + uuid: string; +} + interface ContentOverviewProps { dataTable: TData[]; columns: ColumnDef[]; diff --git a/src/pages/dashboard/components/HeaderDashboard.tsx b/src/pages/dashboard/components/HeaderDashboard.tsx index 5585f25e6..2f41446fe 100644 --- a/src/pages/dashboard/components/HeaderDashboard.tsx +++ b/src/pages/dashboard/components/HeaderDashboard.tsx @@ -35,7 +35,7 @@ const HeaderDashboard = (props: HeaderDashboardProps) => { const [programmeOptions, setProgrammeOptions] = useState([]); const t = useT(); const router = useRouter(); - const { filters, setFilters, setSearchTerm } = useDashboardContext(); + const { filters, setFilters, setSearchTerm, setFrameworks } = useDashboardContext(); const { activeProjects } = useDashboardData(filters); const optionMenu = activeProjects @@ -87,6 +87,7 @@ const HeaderDashboard = (props: HeaderDashboardProps) => { title: framework.name!, value: framework.framework_slug! })); + setFrameworks(frameworks); setProgrammeOptions(options); } }, [frameworks]); @@ -103,7 +104,8 @@ const HeaderDashboard = (props: HeaderDashboardProps) => { icon: "" } }, - organizations: [] + organizations: [], + uuid: "" }); }; useEffect(() => { @@ -112,7 +114,8 @@ const HeaderDashboard = (props: HeaderDashboardProps) => { programmes: filters.programmes, landscapes: filters.landscapes, country: filters.country?.country_slug || undefined, - organizations: filters.organizations + organizations: filters.organizations, + uuid: filters.uuid }; Object.keys(query).forEach(key => !query[key]?.length && delete query[key]); @@ -128,13 +131,14 @@ const HeaderDashboard = (props: HeaderDashboardProps) => { }, [filters]); useEffect(() => { - const { programmes, landscapes, country, organizations } = router.query; + const { programmes, landscapes, country, organizations, uuid } = router.query; const newFilters = { programmes: programmes ? (Array.isArray(programmes) ? programmes : [programmes]) : [], landscapes: landscapes ? (Array.isArray(landscapes) ? landscapes : [landscapes]) : [], country: country ? dashboardCountries.find(c => c.country_slug === country) || filters.country : filters.country, - organizations: organizations ? (Array.isArray(organizations) ? organizations : [organizations]) : [] + organizations: organizations ? (Array.isArray(organizations) ? organizations : [organizations]) : [], + uuid: (uuid as string) || "" }; setFilters(newFilters); diff --git a/src/pages/dashboard/hooks/useDashboardData.ts b/src/pages/dashboard/hooks/useDashboardData.ts index 8efceb893..ce2d80597 100644 --- a/src/pages/dashboard/hooks/useDashboardData.ts +++ b/src/pages/dashboard/hooks/useDashboardData.ts @@ -9,6 +9,7 @@ import { useGetV2DashboardGetProjects, useGetV2DashboardIndicatorHectaresRestoration, useGetV2DashboardJobsCreated, + useGetV2DashboardProjectDetailsProject, useGetV2DashboardTopTreesPlanted, useGetV2DashboardTotalSectionHeader, useGetV2DashboardTreeRestorationGoal, @@ -66,10 +67,12 @@ export const useDashboardData = (filters: any) => { programmes: filters.programmes, country: filters.country.country_slug, "organisations.type": filters.organizations, - landscapes: filters.landscapes + landscapes: filters.landscapes, + "v2_projects.uuid": filters.uuid }; setUpdateFilters(parsedFilters); }, [filters]); + const queryParams: any = useMemo(() => createQueryParams(updateFilters), [updateFilters]); const { showLoader, hideLoader } = useLoading(); const { @@ -110,6 +113,10 @@ export const useDashboardData = (filters: any) => { const { data: hectaresUnderRestoration } = useGetV2DashboardIndicatorHectaresRestoration({ queryParams: queryParams }); + const { data: dashboardProjectDetails } = useGetV2DashboardProjectDetailsProject( + { pathParams: { project: filters.uuid } }, + { enabled: !!filters.uuid } + ); useEffect(() => { if (topData?.data) { @@ -150,6 +157,7 @@ export const useDashboardData = (filters: any) => { numberTreesPlanted, totalSectionHeader, hectaresUnderRestoration, + dashboardProjectDetails, topProject, refetchTotalSectionHeader, activeCountries, diff --git a/src/pages/dashboard/index.page.tsx b/src/pages/dashboard/index.page.tsx index 2675fa302..8f41d8e8a 100644 --- a/src/pages/dashboard/index.page.tsx +++ b/src/pages/dashboard/index.page.tsx @@ -2,14 +2,20 @@ import { useT } from "@transifex/react"; import { useEffect } from "react"; import { When } from "react-if"; +import Breadcrumbs from "@/components/elements/Breadcrumbs/Breadcrumbs"; import Text from "@/components/elements/Text/Text"; import ToolTip from "@/components/elements/Tooltip/Tooltip"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import PageCard from "@/components/extensive/PageElements/Card/PageCard"; import PageRow from "@/components/extensive/PageElements/Row/PageRow"; -import { CHART_TYPES, JOBS_CREATED_CHART_TYPE } from "@/constants/dashboardConsts"; +import { CHART_TYPES, JOBS_CREATED_CHART_TYPE, ORGANIZATIONS_TYPES } from "@/constants/dashboardConsts"; import { useDashboardContext } from "@/context/dashboard.provider"; -import { formatLabelsVolunteers, parseHectaresUnderRestorationData } from "@/utils/dashboardUtils"; +import { + formatLabelsVolunteers, + getFrameworkName, + parseDataToObjetive, + parseHectaresUnderRestorationData +} from "@/utils/dashboardUtils"; import ContentOverview from "./components/ContentOverview"; import SecDashboard from "./components/SecDashboard"; @@ -49,7 +55,7 @@ export interface GraphicLegendProps { const Dashboard = () => { const t = useT(); - const { filters } = useDashboardContext(); + const { filters, setFilters, frameworks } = useDashboardContext(); const { dashboardHeader, dashboardRestorationGoalData, @@ -58,6 +64,7 @@ const Dashboard = () => { totalSectionHeader, hectaresUnderRestoration, numberTreesPlanted, + dashboardProjectDetails, topProject, refetchTotalSectionHeader, centroidsDataProjects, @@ -140,11 +147,19 @@ const Dashboard = () => { header: "", accessorKey: "link", enableSorting: false, - cell: () => { + cell: ({ row }: { row: { original: { uuid: string } } }) => { + const uuid = row.original.uuid; + const handleClick = () => { + setFilters(prevValues => ({ + ...prevValues, + uuid: uuid + })); + }; + return ( - + ); } } @@ -231,7 +246,7 @@ const Dashboard = () => {
- +
{t("results for:")} @@ -242,6 +257,23 @@ const Dashboard = () => {
+ +
+ +
+
{dashboardHeader.map((item, index) => (
@@ -266,7 +298,38 @@ const Dashboard = () => {
))}
- + + +
+ tree +
+ {t(dashboardProjectDetails?.name)} + + {t(`Operations: ${dashboardProjectDetails?.country}`)} + + {t(`Registration: ${dashboardProjectDetails?.country}`)} + + {t( + `Organization: ${ + ORGANIZATIONS_TYPES[dashboardProjectDetails?.organisation as keyof typeof ORGANIZATIONS_TYPES] + }` + )} + +
+
+ +
+
{ chartType={CHART_TYPES.multiLineChart} tooltip={t(NUMBER_OF_TREES_PLANTED_BY_YEAR_TOOLTIP)} /> - + + + {
opt.value === value); return option ? option.title : value; }; - console.log(hectaresUnderRestoration); const restorationStrategiesRepresented = objectToArray( hectaresUnderRestoration?.restoration_strategies_represented ).map(item => ({ @@ -326,3 +343,18 @@ export const parseHectaresUnderRestorationData = ( graphicTargetLandUseTypes }; }; + +export const parseDataToObjetive = (data: InputData): Objetive => { + const objetiveText = data?.descriptionObjetive; + + return { + objetiveText, + preferredLanguage: "English", + landTenure: data?.landTenure ? data?.landTenure : "Unknown" + }; +}; + +export const getFrameworkName = (frameworks: any[], frameworkKey: string): string | undefined => { + const framework = frameworks.find(fw => fw.framework_slug === frameworkKey); + return framework ? framework.name : undefined; +}; From 1bef7d32d8e768497478dbe2429d8a5998ee8987 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 25 Oct 2024 09:51:25 -0700 Subject: [PATCH 079/102] [TM-1269] Get us on the same node version as the microservices repo. --- .nvmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nvmrc b/.nvmrc index 603606bc9..2dbbe00e6 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18.17.0 +20.11.1 From 5b5cb7c046fa319f7d1a9d325fdc07a3ca689276 Mon Sep 17 00:00:00 2001 From: Dotnara Condori Date: Fri, 25 Oct 2024 15:44:51 -0400 Subject: [PATCH 080/102] [TM-1313] Z-index position menu in table active project (#594) * TM-1313 z-index position menu in table active project * update storyshot --- src/components/elements/Table/TableVariants.ts | 2 +- .../elements/Table/__snapshots__/Table.stories.storyshot | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/elements/Table/TableVariants.ts b/src/components/elements/Table/TableVariants.ts index 4f66d17ce..4bf8fe8db 100644 --- a/src/components/elements/Table/TableVariants.ts +++ b/src/components/elements/Table/TableVariants.ts @@ -127,7 +127,7 @@ export const VARIANT_TABLE_DASHBOARD_COUNTRIES = { table: "border-collapse", name: "border-airtable", tableWrapper: "border border-neutral-200 rounded-lg overflow-auto max-h-[267px] lg:max-h-[284px] wide:max-h-[304px]", - trHeader: "bg-neutral-150 sticky top-0 z-10 ", + trHeader: "bg-neutral-150 sticky top-0 z-auto", thHeader: "text-nowrap first:pl-3 first:pr-2 last:pl-2 last:pr-3 border-y border-neutral-200 text-12 px-3 border-t-0", tBody: "", trBody: "bg-white border-y border-neutral-200 last:border-b-0", diff --git a/src/components/elements/Table/__snapshots__/Table.stories.storyshot b/src/components/elements/Table/__snapshots__/Table.stories.storyshot index 89ce39a36..e826f8802 100644 --- a/src/components/elements/Table/__snapshots__/Table.stories.storyshot +++ b/src/components/elements/Table/__snapshots__/Table.stories.storyshot @@ -5901,7 +5901,7 @@ exports[`Storyshots Components/Elements/Table Table Dashboard Countries 1`] = ` className="bg-blueCustom-100 " > Date: Fri, 25 Oct 2024 16:22:37 -0400 Subject: [PATCH 081/102] [TM-1407] update active project table dynamically (#595) * [TM-1407] update active project table dynamically * [TM-1407] remove uuid when filters change --- .../components/DashboardBreadcrumbs.tsx | 106 ++++++++++++++++++ .../dashboard/components/HeaderDashboard.tsx | 4 + src/pages/dashboard/hooks/useDashboardData.ts | 11 +- src/pages/dashboard/index.page.tsx | 73 ++++++------ .../dashboard/project-list/index.page.tsx | 2 +- 5 files changed, 161 insertions(+), 35 deletions(-) create mode 100644 src/pages/dashboard/components/DashboardBreadcrumbs.tsx diff --git a/src/pages/dashboard/components/DashboardBreadcrumbs.tsx b/src/pages/dashboard/components/DashboardBreadcrumbs.tsx new file mode 100644 index 000000000..f92be3065 --- /dev/null +++ b/src/pages/dashboard/components/DashboardBreadcrumbs.tsx @@ -0,0 +1,106 @@ +import classNames from "classnames"; +import { Fragment } from "react"; + +import Text from "@/components/elements/Text/Text"; +import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; +import { useDashboardContext } from "@/context/dashboard.provider"; +import { TextVariants } from "@/types/common"; + +interface DashboardBreadcrumbsProps { + className?: string; + clasNameText?: string; + textVariant?: TextVariants; + framework: string; + countryId?: number; + countryName?: string; + countrySlug?: string; + projectName?: string; +} + +const DashboardBreadcrumbs = ({ + className, + clasNameText, + textVariant, + framework, + countryId, + countryName, + countrySlug, + projectName +}: DashboardBreadcrumbsProps) => { + const { setFilters } = useDashboardContext(); + + const links = [ + { + title: framework, + onClick: () => + setFilters(prevValues => ({ + ...prevValues, + programme: "terrafund", + country: { + country_slug: "", + id: 0, + data: { + label: "", + icon: "" + } + }, + uuid: "" + })) + }, + countryName + ? { + title: countryName, + onClick: () => + setFilters(prevValues => ({ + ...prevValues, + uuid: "" + })) + } + : null, + projectName + ? { + title: projectName + } + : null + ].filter(Boolean); + + return ( +
+ {links.map( + (item, index) => + item && ( + + {item.onClick ? ( + + ) : ( + + {item.title} + + )} + {index < links.length - 1 && ( + + )} + + ) + )} +
+ ); +}; + +export default DashboardBreadcrumbs; diff --git a/src/pages/dashboard/components/HeaderDashboard.tsx b/src/pages/dashboard/components/HeaderDashboard.tsx index 2f41446fe..ee0900689 100644 --- a/src/pages/dashboard/components/HeaderDashboard.tsx +++ b/src/pages/dashboard/components/HeaderDashboard.tsx @@ -147,6 +147,7 @@ const HeaderDashboard = (props: HeaderDashboardProps) => { const handleChange = (selectName: string, value: OptionValue[]) => { setFilters(prevValues => ({ ...prevValues, + uuid: "", [selectName]: value })); }; @@ -158,11 +159,13 @@ const HeaderDashboard = (props: HeaderDashboardProps) => { setSelectedCountry(selectedCountry); setFilters(prevValues => ({ ...prevValues, + uuid: "", country: selectedCountry })); } else { setFilters(prevValues => ({ ...prevValues, + uuid: "", country: { country_slug: "", id: 0, @@ -268,6 +271,7 @@ const HeaderDashboard = (props: HeaderDashboardProps) => { setSelectedCountry(undefined); setFilters(prevValues => ({ ...prevValues, + uuid: "", country: { country_slug: "", id: 0, diff --git a/src/pages/dashboard/hooks/useDashboardData.ts b/src/pages/dashboard/hooks/useDashboardData.ts index ce2d80597..8153d9aab 100644 --- a/src/pages/dashboard/hooks/useDashboardData.ts +++ b/src/pages/dashboard/hooks/useDashboardData.ts @@ -74,6 +74,15 @@ export const useDashboardData = (filters: any) => { }, [filters]); const queryParams: any = useMemo(() => createQueryParams(updateFilters), [updateFilters]); + + const activeProjectsQueryParams: any = useMemo(() => { + const modifiedFilters = { + ...updateFilters, + "v2_projects.uuid": "" + }; + return createQueryParams(modifiedFilters); + }, [updateFilters]); + const { showLoader, hideLoader } = useLoading(); const { data: totalSectionHeader, @@ -93,7 +102,7 @@ export const useDashboardData = (filters: any) => { const { searchTerm } = useDashboardContext(); const { data: activeProjects } = useGetV2DashboardActiveProjects( - { queryParams: queryParams }, + { queryParams: activeProjectsQueryParams }, { enabled: !!searchTerm || !!filters } ); diff --git a/src/pages/dashboard/index.page.tsx b/src/pages/dashboard/index.page.tsx index 8f41d8e8a..61da0dd4c 100644 --- a/src/pages/dashboard/index.page.tsx +++ b/src/pages/dashboard/index.page.tsx @@ -2,7 +2,6 @@ import { useT } from "@transifex/react"; import { useEffect } from "react"; import { When } from "react-if"; -import Breadcrumbs from "@/components/elements/Breadcrumbs/Breadcrumbs"; import Text from "@/components/elements/Text/Text"; import ToolTip from "@/components/elements/Tooltip/Tooltip"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; @@ -18,6 +17,7 @@ import { } from "@/utils/dashboardUtils"; import ContentOverview from "./components/ContentOverview"; +import DashboardBreadcrumbs from "./components/DashboardBreadcrumbs"; import SecDashboard from "./components/SecDashboard"; import { ACTIVE_COUNTRIES_TOOLTIP, @@ -184,25 +184,23 @@ const Dashboard = () => { ) : []; - const DATA_ACTIVE_COUNTRY = activeProjects - ? activeProjects?.map( - (item: { - uuid: string; - name: string; - hectares_under_restoration: number; - trees_under_restoration: number; - jobs_created: number; - volunteers: number; - }) => ({ - uuid: item.uuid, - project: item?.name, - treesPlanted: item.trees_under_restoration.toLocaleString(), - restorationHectares: item.hectares_under_restoration.toLocaleString(), - jobsCreated: item.jobs_created.toLocaleString(), - volunteers: item.volunteers.toLocaleString() - }) - ) - : []; + const mapActiveProjects = (excludeUUID?: string) => { + return activeProjects + ? activeProjects + .filter((item: { uuid: string }) => !excludeUUID || item.uuid !== excludeUUID) + .map((item: any) => ({ + uuid: item.uuid, + project: item.name, + treesPlanted: item.trees_under_restoration.toLocaleString(), + restorationHectares: item.hectares_under_restoration.toLocaleString(), + jobsCreated: item.jobs_created.toLocaleString(), + volunteers: item.volunteers.toLocaleString() + })) + : []; + }; + + const DATA_ACTIVE_COUNTRY = mapActiveProjects(); + const DATA_ACTIVE_COUNTRY_WITHOUT_UUID = mapActiveProjects(filters.uuid); const parseJobCreatedByType = (data: any, type: string) => { if (!data) return { type, chartData: [] }; @@ -259,18 +257,15 @@ const Dashboard = () => {
-
@@ -454,10 +449,22 @@ const Dashboard = () => {
{ project: item?.name, organization: item?.organisation, programme: item?.programme, - country: { label: item?.project_country, image: `/flags/${item?.country_slug.toLowerCase()}.svg` }, + country: { label: item?.project_country, image: `/flags/${item?.country_slug?.toLowerCase()}.svg` }, treesPlanted: item.trees_under_restoration.toLocaleString(), restorationHectares: item.hectares_under_restoration.toLocaleString(), jobsCreated: item.jobs_created.toLocaleString() From 6a3fa7de4aaed84af2831d0a552232a0d3c88c89 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 28 Oct 2024 08:21:30 -0700 Subject: [PATCH 082/102] [TM-1269] Implement delayed job response as a middleware on apiFetcher. --- src/connections/DelayedJob.ts | 26 +++++++++++++ src/generated/apiFetcher.ts | 71 ++++++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/connections/DelayedJob.ts b/src/connections/DelayedJob.ts index 8c624c79b..b6aadbe02 100644 --- a/src/connections/DelayedJob.ts +++ b/src/connections/DelayedJob.ts @@ -7,6 +7,7 @@ import { useConnection } from "@/hooks/useConnection"; import { useValueChanged } from "@/hooks/useValueChanged"; import { ApiDataStore } from "@/store/apiSlice"; import { Connected, Connection } from "@/types/connection"; +import { loadConnection } from "@/utils/loadConnection"; import { selectorCache } from "@/utils/selectorCache"; type DelayedJobConnection = { @@ -67,3 +68,28 @@ export const useDelayedJobResult = (delayedJobId?: string): Connected(fetchPromise: Promise<{ job_uuid?: string }>): Promise { + const initialResult = await fetchPromise; + if (initialResult.job_uuid != null) { + return initialResult as T; + } + + const loadJob = () => loadConnection(delayedJobConnection, { delayedJobId: initialResult.job_uuid }); + + // Load the job initially, and then keep loading the job after the defined timeout until we either + // get a job find failed, or the job status is not pending. + let jobResult: DelayedJobConnection; + for ( + jobResult = await loadJob(); + !jobResult.jobFindFailed && jobResult.job!.status === "pending"; + jobResult = await loadJob() + ) { + await new Promise(resolve => setTimeout(resolve, JOB_POLL_TIMEOUT)); + } + + if (jobResult.jobFindFailed) throw new Error("The jobs endpoint encountered an error"); + if (jobResult.job!.status === "failed") throw new Error("Delayed job failed"); + + return jobResult.job!.payload as T; +} diff --git a/src/generated/apiFetcher.ts b/src/generated/apiFetcher.ts index 2b3b621f2..4a04c09f6 100644 --- a/src/generated/apiFetcher.ts +++ b/src/generated/apiFetcher.ts @@ -97,7 +97,10 @@ export async function apiFetch< } if (response.headers.get("content-type")?.includes("json")) { - return await response.json(); + const payload = await response.json(); + if (payload.job_uuid == null) return payload; + + return await processDelayedJob(signal, payload.job_uuid); } else { // if it is not a json response, assume it is a blob and cast it to TData return (await response.blob()) as unknown as TData; @@ -118,3 +121,69 @@ const resolveUrl = (url: string, queryParams: Record = {}, pathP if (query) query = `?${query}`; return url.replace(/\{\w*\}/g, key => pathParams[key.slice(1, -1)]) + query; }; + +const JOB_POLL_TIMEOUT = 300; // in ms + +type JobResult = { + data: { + attributes: { + status: "pending" | "failed" | "succeeded"; + statusCode: number | null; + payload: object | null; + }; + }; +}; + +async function loadJob(signal: AbortSignal | undefined, delayedJobId: string): Promise { + let response, error; + try { + const headers: HeadersInit = { "Content-Type": "application/json" }; + const accessToken = typeof window !== "undefined" && getAccessToken(); + if (accessToken != null) headers.Authorization = `Bearer ${accessToken}`; + + response = await fetch(`${baseUrl}/jobs/v3/delayedJobs/${delayedJobId}`, { signal, headers }); + if (!response.ok) { + try { + error = { + statusCode: response.status, + ...(await response.json()) + }; + } catch (e) { + error = { statusCode: -1 }; + } + + throw error; + } + + return await response.json(); + } catch (e) { + Log.error("Delayed Job Fetch error", e); + error = { + statusCode: response?.status || -1, + //@ts-ignore + ...(e || {}) + }; + throw error; + } +} + +async function processDelayedJob(signal: AbortSignal | undefined, delayedJobId: string): Promise { + const headers: HeadersInit = { "Content-Type": "application/json" }; + const accessToken = typeof window !== "undefined" && getAccessToken(); + if (accessToken != null) headers.Authorization = `Bearer ${accessToken}`; + + let jobResult; + for ( + jobResult = await loadJob(signal, delayedJobId); + jobResult.data?.attributes?.status === "pending"; + jobResult = await loadJob(signal, delayedJobId) + ) { + if (signal?.aborted) throw new Error("Aborted"); + await new Promise(resolve => setTimeout(resolve, JOB_POLL_TIMEOUT)); + } + + const { status, statusCode, payload } = jobResult.data!.attributes; + if (status === "failed") throw { statusCode, ...payload }; + + return payload as TData; +} From 67089b36fe25df0685e417cbb6205ee0af2eb3b0 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 28 Oct 2024 15:24:33 -0700 Subject: [PATCH 083/102] [TM-1269] Change how we host v3 services to avoid the Api Gateway locally. --- README.md | 11 ++- openapi-codegen.config.ts | 8 +- package.json | 3 +- src/connections/DelayedJob.ts | 95 ------------------- .../v3/jobService/jobServiceComponents.ts | 78 --------------- .../v3/jobService/jobServiceFetcher.ts | 6 -- .../v3/jobService/jobServicePredicates.ts | 14 --- .../v3/jobService/jobServiceSchemas.ts | 19 ---- src/generated/v3/utils.ts | 28 +++++- src/store/apiSlice.ts | 4 +- 10 files changed, 40 insertions(+), 226 deletions(-) delete mode 100644 src/connections/DelayedJob.ts delete mode 100644 src/generated/v3/jobService/jobServiceComponents.ts delete mode 100644 src/generated/v3/jobService/jobServiceFetcher.ts delete mode 100644 src/generated/v3/jobService/jobServicePredicates.ts delete mode 100644 src/generated/v3/jobService/jobServiceSchemas.ts diff --git a/README.md b/README.md index 1651703e1..4306ecef8 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,9 @@ The V3 API has a different API layer, but the generation is similar: yarn generate:services ``` -When adding a new **service** app to the v3 API, a few steps are needed to integrate it: -* In `openapi-codegen.config.ts`, add the new service name to the `SERVICES` array (e.g. `foo-service`). +When adding a new **service** app to the v3 API: +* In your local .env, define the service URL +* In `openapi-codegen.config.ts`, add the new service name to the `SERVICES` object (e.g. `foo-service`). * This will generate a new target, which needs to be added to `package.json`: * Under scripts, add `"generate:fooService": "npm run generate:fooService"` * Under the `"generate:services"` script, add the new service: `"generate:services": "npm run generate:userService && npm run generate:fooService` @@ -50,8 +51,12 @@ When adding a new **service** app to the v3 API, a few steps are needed to integ modify it to match `userServiceFetcher.ts`. * This file does not get regenerated after the first time, and so it can utilize the same utilities for interfacing with the redux API layer / connection system that the other v3 services use. +* Follow directions below for all namespaces and resources in the new service -When adding a new **resource** to the v3 API, a couple of steps are needed to integrate it: +When adding a new **namespace** to the V3 API: +* In `geneated/v3/utils.ts`, add namespace -> service URL mapping to `V3_NAMESPACES` + +When adding a new **resource** to the v3 API: * The resource needs to be specified in shape of the redux API store. In `apiSlice.ts`, add the new resource plural name (the `type` returned in the API responses) to the store by adding it to the `RESOURCES` const. This will make sure it's listed in the type of the ApiStore so that resources that match that type are seamlessly folded into the store cache structure. diff --git a/openapi-codegen.config.ts b/openapi-codegen.config.ts index 26baaa3ea..8fdd9e3e8 100644 --- a/openapi-codegen.config.ts +++ b/openapi-codegen.config.ts @@ -24,7 +24,9 @@ dotenv.config(); // are namespaced by feature set rather than service (a service may contain multiple namespaces), we // isolate the generated API integration by service to make it easier for a developer to find where // the associated BE code is for a given FE API integration. -const SERVICES = ["user-service", "job-service"]; +const SERVICES = { + "user-service": process.env.NEXT_PUBLIC_USER_SERVICE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL +}; const config: Record = { api: { @@ -63,12 +65,12 @@ const config: Record = { } }; -for (const service of SERVICES) { +for (const [service, baseUrl] of Object.entries(SERVICES)) { const name = _.camelCase(service); config[name] = { from: { source: "url", - url: `${process.env.NEXT_PUBLIC_API_BASE_URL}/${service}/documentation/api-json` + url: `${baseUrl}/${service}/documentation/api-json` }, outputDir: `src/generated/v3/${name}`, to: async context => { diff --git a/package.json b/package.json index f70c2095e..a61cc42ed 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,7 @@ "build-storybook": "storybook build", "generate:api": "openapi-codegen gen api", "generate:userService": "openapi-codegen gen userService", - "generate:jobService": "openapi-codegen gen jobService", - "generate:services": "npm run generate:userService && npm run generate:jobService", + "generate:services": "npm run generate:userService", "tx:push": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli push --key-generator=hash src/ --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET", "tx:pull": "eval $(grep '^TRANSIFEX_TOKEN' .env) && eval $(grep '^TRANSIFEX_SECRET' .env) && txjs-cli pull --token=$TRANSIFEX_TOKEN --secret=$TRANSIFEX_SECRET" }, diff --git a/src/connections/DelayedJob.ts b/src/connections/DelayedJob.ts deleted file mode 100644 index b6aadbe02..000000000 --- a/src/connections/DelayedJob.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { createSelector } from "reselect"; - -import { delayedJobsFind } from "@/generated/v3/jobService/jobServiceComponents"; -import { delayedJobsFindFetchFailed, delayedJobsFindIsFetching } from "@/generated/v3/jobService/jobServicePredicates"; -import { DelayedJobDto } from "@/generated/v3/jobService/jobServiceSchemas"; -import { useConnection } from "@/hooks/useConnection"; -import { useValueChanged } from "@/hooks/useValueChanged"; -import { ApiDataStore } from "@/store/apiSlice"; -import { Connected, Connection } from "@/types/connection"; -import { loadConnection } from "@/utils/loadConnection"; -import { selectorCache } from "@/utils/selectorCache"; - -type DelayedJobConnection = { - job?: DelayedJobDto; - findingJob: boolean; - jobFindFailed: boolean; -}; - -type DelayedJobConnectionProps = { - delayedJobId?: string; -}; - -const delayedJobSelector = (delayedJobId?: string) => (store: ApiDataStore) => - delayedJobId == null ? undefined : store.delayedJobs?.[delayedJobId]; -const findDelayedJobProps = (delayedJobId?: string) => ({ pathParams: { uuid: delayedJobId ?? "missingId" } }); -const findDelayedJob = (delayedJobId: string) => delayedJobsFind(findDelayedJobProps(delayedJobId)); - -const delayedJobConnection: Connection = { - load: ({ jobFindFailed, job }, { delayedJobId }) => { - if (delayedJobId != null && job == null && !jobFindFailed) findDelayedJob(delayedJobId); - }, - - isLoaded: ({ jobFindFailed, job }, { delayedJobId }) => delayedJobId == null || job != null || jobFindFailed, - - selector: selectorCache( - ({ delayedJobId }) => delayedJobId ?? "", - ({ delayedJobId }) => - createSelector( - [ - delayedJobSelector(delayedJobId), - delayedJobsFindIsFetching(findDelayedJobProps(delayedJobId)), - delayedJobsFindFetchFailed(findDelayedJobProps(delayedJobId)) - ], - (job, jobLoading, jobLoadFailure) => ({ - job: job?.attributes, - findingJob: jobLoading, - jobFindFailed: jobLoadFailure != null - }) - ) - ) -}; - -const JOB_POLL_TIMEOUT = 300; // in ms -export const useDelayedJobResult = (delayedJobId?: string): Connected => { - const [loaded, jobResult] = useConnection(delayedJobConnection); - useValueChanged(jobResult?.job, () => { - if (delayedJobId != null && jobResult?.job?.status === "pending") { - // If we received an updated job and the status is pending, ask the server again after the - // defined timeout - setTimeout(() => { - findDelayedJob(delayedJobId); - }, JOB_POLL_TIMEOUT); - } - }); - - // Don't claim to our parent component that we're done loading until the connection is loaded - // and either there is no job (there's a failure instead) or the job status is not pending. - const status = jobResult?.job?.status; - return loaded && status !== "pending" ? [true, jobResult] : [false, {}]; -}; - -export async function loadDelayedJobResult(fetchPromise: Promise<{ job_uuid?: string }>): Promise { - const initialResult = await fetchPromise; - if (initialResult.job_uuid != null) { - return initialResult as T; - } - - const loadJob = () => loadConnection(delayedJobConnection, { delayedJobId: initialResult.job_uuid }); - - // Load the job initially, and then keep loading the job after the defined timeout until we either - // get a job find failed, or the job status is not pending. - let jobResult: DelayedJobConnection; - for ( - jobResult = await loadJob(); - !jobResult.jobFindFailed && jobResult.job!.status === "pending"; - jobResult = await loadJob() - ) { - await new Promise(resolve => setTimeout(resolve, JOB_POLL_TIMEOUT)); - } - - if (jobResult.jobFindFailed) throw new Error("The jobs endpoint encountered an error"); - if (jobResult.job!.status === "failed") throw new Error("Delayed job failed"); - - return jobResult.job!.payload as T; -} diff --git a/src/generated/v3/jobService/jobServiceComponents.ts b/src/generated/v3/jobService/jobServiceComponents.ts deleted file mode 100644 index 5329fbc8a..000000000 --- a/src/generated/v3/jobService/jobServiceComponents.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Generated by @openapi-codegen - * - * @version 1.0 - */ -import type * as Fetcher from "./jobServiceFetcher"; -import { jobServiceFetch } from "./jobServiceFetcher"; -import type * as Schemas from "./jobServiceSchemas"; - -export type DelayedJobsFindPathParams = { - uuid: string; -}; - -export type DelayedJobsFindError = Fetcher.ErrorWrapper< - | { - status: 401; - payload: { - /** - * @example 401 - */ - statusCode: number; - /** - * @example Unauthorized - */ - message: string; - /** - * @example Unauthorized - */ - error?: string; - }; - } - | { - status: 404; - payload: { - /** - * @example 404 - */ - statusCode: number; - /** - * @example Not Found - */ - message: string; - /** - * @example Not Found - */ - error?: string; - }; - } ->; - -export type DelayedJobsFindResponse = { - data?: { - /** - * @example delayedJobs - */ - type?: string; - /** - * @format uuid - */ - id?: string; - attributes?: Schemas.DelayedJobDto; - }; -}; - -export type DelayedJobsFindVariables = { - pathParams: DelayedJobsFindPathParams; -}; - -/** - * Get the current status and potentially payload or error from a delayed job. - */ -export const delayedJobsFind = (variables: DelayedJobsFindVariables, signal?: AbortSignal) => - jobServiceFetch({ - url: "/jobs/v3/delayedJobs/{uuid}", - method: "get", - ...variables, - signal - }); diff --git a/src/generated/v3/jobService/jobServiceFetcher.ts b/src/generated/v3/jobService/jobServiceFetcher.ts deleted file mode 100644 index 97ea202d3..000000000 --- a/src/generated/v3/jobService/jobServiceFetcher.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This type is imported in the auto generated `jobServiceComponents` file, so it needs to be -// exported from this file. -export type { ErrorWrapper } from "../utils"; - -// The serviceFetch method is the shared fetch method for all service fetchers. -export { serviceFetch as jobServiceFetch } from "../utils"; diff --git a/src/generated/v3/jobService/jobServicePredicates.ts b/src/generated/v3/jobService/jobServicePredicates.ts deleted file mode 100644 index 348490ef9..000000000 --- a/src/generated/v3/jobService/jobServicePredicates.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { isFetching, fetchFailed } from "../utils"; -import { ApiDataStore } from "@/store/apiSlice"; -import { DelayedJobsFindPathParams, DelayedJobsFindVariables } from "./jobServiceComponents"; - -export const delayedJobsFindIsFetching = (variables: DelayedJobsFindVariables) => (store: ApiDataStore) => - isFetching<{}, DelayedJobsFindPathParams>({ store, url: "/jobs/v3/delayedJobs/{uuid}", method: "get", ...variables }); - -export const delayedJobsFindFetchFailed = (variables: DelayedJobsFindVariables) => (store: ApiDataStore) => - fetchFailed<{}, DelayedJobsFindPathParams>({ - store, - url: "/jobs/v3/delayedJobs/{uuid}", - method: "get", - ...variables - }); diff --git a/src/generated/v3/jobService/jobServiceSchemas.ts b/src/generated/v3/jobService/jobServiceSchemas.ts deleted file mode 100644 index 179ba4d2a..000000000 --- a/src/generated/v3/jobService/jobServiceSchemas.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Generated by @openapi-codegen - * - * @version 1.0 - */ -export type DelayedJobDto = { - /** - * The current status of the job. If the status is not pending, the payload and statusCode will be provided. - */ - status: "pending" | "failed" | "succeeded"; - /** - * If the job is out of pending state, this is the HTTP status code for the completed process - */ - statusCode: number | null; - /** - * If the job is out of pending state, this is the JSON payload for the completed process - */ - payload: string | null; -}; diff --git a/src/generated/v3/utils.ts b/src/generated/v3/utils.ts index 6378cd64c..e9cae82cf 100644 --- a/src/generated/v3/utils.ts +++ b/src/generated/v3/utils.ts @@ -2,8 +2,6 @@ import ApiSlice, { ApiDataStore, isErrorState, isInProgress, Method, PendingErro import Log from "@/utils/log"; import { selectLogin } from "@/connections/Login"; -const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; - export type ErrorWrapper = TError | { statusCode: -1; message: string }; type SelectorOptions = { @@ -14,6 +12,29 @@ type SelectorOptions = { pathParams?: TPathParams; }; +const USER_SERVICE_URL = process.env.NEXT_PUBLIC_USER_SERVICE_URL ?? ""; +const JOB_SERVICE_URL = process.env.NEXT_PUBLIC_JOB_SERVICE_URL ?? ""; +const V3_NAMESPACES: Record = { + auth: USER_SERVICE_URL, + users: USER_SERVICE_URL, + jobs: JOB_SERVICE_URL +} as const; + +const getBaseUrl = (url: string) => { + if (process.env.NEXT_PUBLIC_API_BASE_URL?.startsWith("https:")) { + return process.env.NEXT_PUBLIC_API_BASE_URL; + } + + // The v3 space is divided into services, and each service may host multiple namespaces. In + // local dev, we don't use a proxy, so the FE needs to know how to connect to each service + // individually. + const namespace = url.substring(1).split("/")[0]; + const baseUrl = V3_NAMESPACES[namespace]; + if (baseUrl == null) throw new Error(`Namespace not defined! [${namespace}]`); + + return baseUrl; +}; + export const resolveUrl = ( url: string, queryParams: Record = {}, @@ -26,7 +47,8 @@ export const resolveUrl = ( searchParams.sort(); let query = searchParams.toString(); if (query) query = `?${query}`; - return `${baseUrl}${url.replace(/\{\w*}/g, key => pathParams[key.slice(1, -1)]) + query}`; + + return `${getBaseUrl(url)}${url.replace(/\{\w*}/g, key => pathParams[key.slice(1, -1)]) + query}`; }; export function isFetching({ diff --git a/src/store/apiSlice.ts b/src/store/apiSlice.ts index 8023d2872..ea5c9a13a 100644 --- a/src/store/apiSlice.ts +++ b/src/store/apiSlice.ts @@ -5,7 +5,6 @@ import { HYDRATE } from "next-redux-wrapper"; import { Store } from "redux"; import { setAccessToken } from "@/admin/apiProvider/utils/token"; -import { DelayedJobDto } from "@/generated/v3/jobService/jobServiceSchemas"; import { LoginDto, OrganisationDto, UserDto } from "@/generated/v3/userService/userServiceSchemas"; export type PendingErrorState = { @@ -54,10 +53,9 @@ type StoreResourceMap = Record; logins: StoreResourceMap; organisations: StoreResourceMap; users: StoreResourceMap; From ddc3fdd02d492431cc2a3741da4bfb247ae29525 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 28 Oct 2024 15:41:06 -0700 Subject: [PATCH 084/102] [TM-1269] Resolved the delayed job endpoint as V3. --- src/generated/apiFetcher.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/generated/apiFetcher.ts b/src/generated/apiFetcher.ts index 4a04c09f6..7587b0312 100644 --- a/src/generated/apiFetcher.ts +++ b/src/generated/apiFetcher.ts @@ -1,7 +1,8 @@ -import { getAccessToken } from "../admin/apiProvider/utils/token"; +import { getAccessToken } from "@/admin/apiProvider/utils/token"; import { ApiContext } from "./apiContext"; import FormData from "form-data"; import Log from "@/utils/log"; +import { resolveUrl as resolveV3Url } from "./v3/utils"; const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL + "/api"; @@ -98,9 +99,9 @@ export async function apiFetch< if (response.headers.get("content-type")?.includes("json")) { const payload = await response.json(); - if (payload.job_uuid == null) return payload; + if (payload.data?.job_uuid == null) return payload; - return await processDelayedJob(signal, payload.job_uuid); + return await processDelayedJob(signal, payload.data.job_uuid); } else { // if it is not a json response, assume it is a blob and cast it to TData return (await response.blob()) as unknown as TData; @@ -122,7 +123,7 @@ const resolveUrl = (url: string, queryParams: Record = {}, pathP return url.replace(/\{\w*\}/g, key => pathParams[key.slice(1, -1)]) + query; }; -const JOB_POLL_TIMEOUT = 300; // in ms +const JOB_POLL_TIMEOUT = 500; // in ms type JobResult = { data: { @@ -141,7 +142,8 @@ async function loadJob(signal: AbortSignal | undefined, delayedJobId: string): P const accessToken = typeof window !== "undefined" && getAccessToken(); if (accessToken != null) headers.Authorization = `Bearer ${accessToken}`; - response = await fetch(`${baseUrl}/jobs/v3/delayedJobs/${delayedJobId}`, { signal, headers }); + const url = resolveV3Url(`/jobs/v3/delayedJobs/${delayedJobId}`); + response = await fetch(url, { signal, headers }); if (!response.ok) { try { error = { From 3e576c67b44ae32f17c198709ae0ecd6487e745e Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Mon, 28 Oct 2024 16:03:20 -0700 Subject: [PATCH 085/102] [TM-1269] Provide examples in the env sample. --- .env.local.sample | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/.env.local.sample b/.env.local.sample index 6e5a58d6e..e683255df 100644 --- a/.env.local.sample +++ b/.env.local.sample @@ -1,20 +1,17 @@ +# Local dev configuration +NEXT_PUBLIC_API_BASE_URL='http://localhost:8080' +NEXT_PUBLIC_USER_SERVICE_URL='http://localhost:4010' +NEXT_PUBLIC_JOB_SERVICE_URL='http://localhost:4020' + +# Accessing staging instead of local BE +NEXT_PUBLIC_API_BASE_URL='https://api-staging.terramatch.org' -# Browser accessible variables -NEXT_PUBLIC_API_BASE_URL = NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN = -NEXT_PUBLIC_SENTRY_DSN = TRANSIFEX_TOKEN = TRANSIFEX_SECRET = TRANSIFEX_TRANSLATIONS_TTL_SEC = 10 -SENTRY_DSN = -SENTRY_AUTH_TOKEN = - -SENTRY_PROJECT = -SENTRY_ORG = - - NEXT_PUBLIC_GEOSERVER_URL = NEXT_PUBLIC_GEOSERVER_WORKSPACE = From fee45aa90aa58511b7e5b6e88f1419087ed42d2c Mon Sep 17 00:00:00 2001 From: diego-morales-flores-1996 <139272044+diego-morales-flores-1996@users.noreply.github.com> Date: Tue, 29 Oct 2024 13:16:36 -0400 Subject: [PATCH 086/102] TM-1407 add read more on objective sec (#598) --- .../dashboard/components/ObjectiveSec.tsx | 25 ++++++++++++++++--- src/pages/dashboard/mockedData/dashboard.ts | 4 ++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/pages/dashboard/components/ObjectiveSec.tsx b/src/pages/dashboard/components/ObjectiveSec.tsx index 48875fcdb..1a40a34fa 100644 --- a/src/pages/dashboard/components/ObjectiveSec.tsx +++ b/src/pages/dashboard/components/ObjectiveSec.tsx @@ -1,4 +1,5 @@ import { useT } from "@transifex/react"; +import { useEffect, useState } from "react"; import { When } from "react-if"; import Text from "@/components/elements/Text/Text"; @@ -7,17 +8,33 @@ import { DashboardDataProps } from "../project/index.page"; const ObjectiveSec = ({ data }: { data: DashboardDataProps }) => { const t = useT(); + const [collapseText, setCollapseText] = useState(true); + const [objectiveText, setObjectiveText] = useState(data.objetiveText); + const maxLength = 660; + + useEffect(() => { + if (collapseText) { + setObjectiveText(data?.objetiveText?.slice(0, maxLength)); + } else { + setObjectiveText(data?.objetiveText); + } + }, [collapseText, data?.objetiveText, objectiveText]); return (
- {t(data.objetiveText)} - - - {t("Read More...")} + {t(objectiveText)} + maxLength}> + +
diff --git a/src/pages/dashboard/mockedData/dashboard.ts b/src/pages/dashboard/mockedData/dashboard.ts index 0bb21e961..5a21f843d 100644 --- a/src/pages/dashboard/mockedData/dashboard.ts +++ b/src/pages/dashboard/mockedData/dashboard.ts @@ -270,7 +270,9 @@ export const DATA_ACTIVE_COUNTRY = [ export const OBJETIVE = { objetiveText: `Goshen Global Vision restores the vitality of the damaged forests of Western Ghana’s cocoa belt, where local incomes and soil quality are falling. Over the past five years, they have worked with 7,000 community members to establish a network of seven nurseries that has grown and planted 220,000 trees across 11,500 hectares. - A grant will enable the organization to mobilize young people and women to grow hundreds of thousands of biodiverse native trees with high economic value, like African mahogany. By planting those trees to shade the crops of struggling cocoa farmers, they will build both economic and climate resilience in this vulnerable region.`, + A grant will enable the organization to mobilize young people and women to grow hundreds of thousands of biodiverse native trees with high economic value, like African mahogany. By planting those trees to shade the crops of struggling cocoa farmers, they will build both economic and climate resilience in this vulnerable region. + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.`, preferredLanguage: "English", landTenure: "Communal Government" }; From 252aee4505ac39bf954813d4d2f70f565747cdce Mon Sep 17 00:00:00 2001 From: diego-morales-flores-1996 <139272044+diego-morales-flores-1996@users.noreply.github.com> Date: Tue, 29 Oct 2024 13:29:13 -0400 Subject: [PATCH 087/102] TM-1313 remove tolltips and image button (#597) * TM-1313 remove tolltips and image button * TM-1313 remove tolltips from project --- src/components/elements/Map-mapbox/Map.tsx | 6 ++++- src/pages/dashboard/[id].page.tsx | 24 +++---------------- .../dashboard/components/ContentOverview.tsx | 5 +++- src/pages/dashboard/hooks/useDashboardData.ts | 11 +++------ src/pages/dashboard/index.page.tsx | 10 -------- src/pages/dashboard/project/index.page.tsx | 11 +-------- 6 files changed, 16 insertions(+), 51 deletions(-) diff --git a/src/components/elements/Map-mapbox/Map.tsx b/src/components/elements/Map-mapbox/Map.tsx index c8606fe4f..3e6cb4805 100644 --- a/src/components/elements/Map-mapbox/Map.tsx +++ b/src/components/elements/Map-mapbox/Map.tsx @@ -118,6 +118,7 @@ interface MapProps extends Omit isDashboard?: "dashboard" | "modal" | undefined; entityData?: any; imageGalleryRef?: React.RefObject; + showImagesButton?: boolean; } export const MapContainer = ({ @@ -150,6 +151,7 @@ export const MapContainer = ({ entityData, imageGalleryRef, centroids, + showImagesButton, ...props }: MapProps) => { const [showMediaPopups, setShowMediaPopups] = useState(true); @@ -567,7 +569,9 @@ export const MapContainer = ({ - + + + {isDashboard === "dashboard" ? ( ) : ( diff --git a/src/pages/dashboard/[id].page.tsx b/src/pages/dashboard/[id].page.tsx index 91534b9e7..940e5df29 100644 --- a/src/pages/dashboard/[id].page.tsx +++ b/src/pages/dashboard/[id].page.tsx @@ -3,27 +3,21 @@ import React from "react"; import { When } from "react-if"; import Text from "@/components/elements/Text/Text"; -import ToolTip from "@/components/elements/Tooltip/Tooltip"; -import { IconNames } from "@/components/extensive/Icon/Icon"; -import Icon from "@/components/extensive/Icon/Icon"; import PageCard from "@/components/extensive/PageElements/Card/PageCard"; import PageRow from "@/components/extensive/PageElements/Row/PageRow"; import { CountriesProps } from "@/components/generic/Layout/DashboardLayout"; import SecDashboard from "./components/SecDashboard"; import { - HECTARES_UNDER_RESTORATION_TOOLTIP, JOBS_CREATED_BY_AGE_TOOLTIP, JOBS_CREATED_BY_GENDER_TOOLTIP, JOBS_CREATED_SECTION_TOOLTIP, - JOBS_CREATED_TOOLTIP, NEW_FULL_TIME_JOBS_TOOLTIP, NEW_PART_TIME_JOBS_TOOLTIP, NUMBER_OF_TREES_PLANTED_BY_YEAR_TOOLTIP, NUMBER_OF_TREES_PLANTED_TOOLTIP, TOP_5_PROJECTS_WITH_MOST_PLANTED_TREES_TOOLTIP, TOTAL_VOLUNTEERS_TOOLTIP, - TREES_PLANTED_TOOLTIP, TREES_RESTORED_SECTION_TOOLTIP, VOLUNTEERS_CREATED_BY_AGE_TOOLTIP, VOLUNTEERS_CREATED_BY_GENDER_TOOLTIP @@ -53,18 +47,15 @@ const Country: React.FC = ({ selectedCountry }) => { const dashboardHeader = [ { label: "Trees Planted", - value: "12.2M", - tooltip: TREES_PLANTED_TOOLTIP + value: "12.2M" }, { label: "Hectares Under Restoration", - value: "5,220 ha", - tooltip: HECTARES_UNDER_RESTORATION_TOOLTIP + value: "5,220 ha" }, { label: "Jobs Created", - value: "23,000", - tooltip: JOBS_CREATED_TOOLTIP + value: "23,000" } ]; @@ -95,15 +86,6 @@ const Country: React.FC = ({ selectedCountry }) => { {t(item.value)} - - -
))} diff --git a/src/pages/dashboard/components/ContentOverview.tsx b/src/pages/dashboard/components/ContentOverview.tsx index 316d52113..677bab846 100644 --- a/src/pages/dashboard/components/ContentOverview.tsx +++ b/src/pages/dashboard/components/ContentOverview.tsx @@ -44,6 +44,7 @@ interface ContentOverviewProps { centroids?: DashboardGetProjectsData[]; dataHectaresUnderRestoration: HectaresUnderRestorationData; polygonsData?: any; + showImagesButton?: boolean; } const ContentOverview = (props: ContentOverviewProps) => { @@ -54,7 +55,8 @@ const ContentOverview = (props: ContentOverviewProps) => { textTooltipTable, centroids, polygonsData, - dataHectaresUnderRestoration + dataHectaresUnderRestoration, + showImagesButton } = props; const t = useT(); const modalMapFunctions = useMap(); @@ -142,6 +144,7 @@ const ContentOverview = (props: ContentOverviewProps) => { className="custom-popup-close-button" centroids={centroids} showPopups={true} + showImagesButton={showImagesButton} polygonsData={polygonsData as Record} />
diff --git a/src/pages/dashboard/hooks/useDashboardData.ts b/src/pages/dashboard/hooks/useDashboardData.ts index 8153d9aab..c4c120856 100644 --- a/src/pages/dashboard/hooks/useDashboardData.ts +++ b/src/pages/dashboard/hooks/useDashboardData.ts @@ -19,25 +19,20 @@ import { import { DashboardTreeRestorationGoalResponse } from "@/generated/apiSchemas"; import { createQueryParams } from "@/utils/dashboardUtils"; -import { HECTARES_UNDER_RESTORATION_TOOLTIP, JOBS_CREATED_TOOLTIP, TREES_PLANTED_TOOLTIP } from "../constants/tooltips"; - export const useDashboardData = (filters: any) => { const [topProject, setTopProjects] = useState([]); const [dashboardHeader, setDashboardHeader] = useState([ { label: "Trees Planted", - value: "0", - tooltip: TREES_PLANTED_TOOLTIP + value: "0" }, { label: "Hectares Under Restoration", - value: "0 ha", - tooltip: HECTARES_UNDER_RESTORATION_TOOLTIP + value: "0 ha" }, { label: "Jobs Created", - value: "0", - tooltip: JOBS_CREATED_TOOLTIP + value: "0" } ]); const projectUuid = filters.project?.project_uuid; diff --git a/src/pages/dashboard/index.page.tsx b/src/pages/dashboard/index.page.tsx index 61da0dd4c..10947117d 100644 --- a/src/pages/dashboard/index.page.tsx +++ b/src/pages/dashboard/index.page.tsx @@ -3,7 +3,6 @@ import { useEffect } from "react"; import { When } from "react-if"; import Text from "@/components/elements/Text/Text"; -import ToolTip from "@/components/elements/Tooltip/Tooltip"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import PageCard from "@/components/extensive/PageElements/Card/PageCard"; import PageRow from "@/components/extensive/PageElements/Row/PageRow"; @@ -280,15 +279,6 @@ const Dashboard = () => { {t(item.value)} - - -
))} diff --git a/src/pages/dashboard/project/index.page.tsx b/src/pages/dashboard/project/index.page.tsx index 2eed4d78c..016f0a3dd 100644 --- a/src/pages/dashboard/project/index.page.tsx +++ b/src/pages/dashboard/project/index.page.tsx @@ -3,7 +3,6 @@ import { useContext } from "react"; import Breadcrumbs from "@/components/elements/Breadcrumbs/Breadcrumbs"; import Text from "@/components/elements/Text/Text"; -import ToolTip from "@/components/elements/Tooltip/Tooltip"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; import PageCard from "@/components/extensive/PageElements/Card/PageCard"; import PageRow from "@/components/extensive/PageElements/Row/PageRow"; @@ -166,15 +165,6 @@ const ProjectView = () => { {t(item.value)} - - -
))} @@ -316,6 +306,7 @@ const ProjectView = () => { graphicTargetLandUseTypes: [] }} textTooltipTable={t(ACTIVE_PROJECTS_TOOLTIP)} + showImagesButton />
); From b503f0a99c2cadbbe04ee16ff1c7bd3907a2361c Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Tue, 29 Oct 2024 11:25:01 -0700 Subject: [PATCH 088/102] [TM-1411] Fix entity edit for admins. --- .../components/EntityEdit/EntityEdit.tsx | 54 ++++++++++--------- .../extensive/WorkdayCollapseGrid/hooks.ts | 6 +-- src/helpers/customForms.ts | 7 +-- 3 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/admin/components/EntityEdit/EntityEdit.tsx b/src/admin/components/EntityEdit/EntityEdit.tsx index 048c2c1e2..252b2e94d 100644 --- a/src/admin/components/EntityEdit/EntityEdit.tsx +++ b/src/admin/components/EntityEdit/EntityEdit.tsx @@ -5,6 +5,7 @@ import { useNavigate, useParams } from "react-router-dom"; import modules from "@/admin/modules"; import WizardForm from "@/components/extensive/WizardForm"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; +import FrameworkProvider from "@/context/framework.provider"; import { GetV2FormsENTITYUUIDResponse, useGetV2FormsENTITYUUID, @@ -65,34 +66,37 @@ export const EntityEdit = () => { if (loadError) { return notFound(); } + return (
- navigate("..")} - onChange={data => - updateEntity({ - pathParams: { uuid: entityUUID, entity: entityName }, - body: { answers: normalizedFormData(data, formSteps!) } - }) - } - formStatus={isSuccess ? "saved" : isUpdating ? "saving" : undefined} - onSubmit={() => navigate(createPath({ resource, id, type: "show" }))} - defaultValues={defaultValues} - title={title} - tabOptions={{ - markDone: true, - disableFutureTabs: true - }} - summaryOptions={{ - title: "Review Details", - downloadButtonText: "Download" - }} - roundedCorners - hideSaveAndCloseButton - /> + + navigate("..")} + onChange={data => + updateEntity({ + pathParams: { uuid: entityUUID, entity: entityName }, + body: { answers: normalizedFormData(data, formSteps!) } + }) + } + formStatus={isSuccess ? "saved" : isUpdating ? "saving" : undefined} + onSubmit={() => navigate(createPath({ resource, id, type: "show" }))} + defaultValues={defaultValues} + title={title} + tabOptions={{ + markDone: true, + disableFutureTabs: true + }} + summaryOptions={{ + title: "Review Details", + downloadButtonText: "Download" + }} + roundedCorners + hideSaveAndCloseButton + /> +
); diff --git a/src/components/extensive/WorkdayCollapseGrid/hooks.ts b/src/components/extensive/WorkdayCollapseGrid/hooks.ts index 3c8c14b0d..b373dc828 100644 --- a/src/components/extensive/WorkdayCollapseGrid/hooks.ts +++ b/src/components/extensive/WorkdayCollapseGrid/hooks.ts @@ -61,8 +61,7 @@ export interface SectionRow { amount: number; } -export function calculateTotals(demographics: Demographic[]) { - const { framework } = useFrameworkContext(); +export function calculateTotals(demographics: Demographic[], framework: Framework) { const initialCounts = getInitialCounts(framework); const counts = demographics.reduce(function (counts, { type, amount }) { const typedType = type as keyof FrameworkDemographicCountTypes; @@ -87,9 +86,10 @@ export function calculateTotals(demographics: Demographic[]) { } export function useTableStatus(demographics: Demographic[]): { total: number; status: Status } { + const { framework } = useFrameworkContext(); return useMemo( function () { - const { total, complete } = calculateTotals(demographics); + const { total, complete } = calculateTotals(demographics, framework); let status: Status = "in-progress"; if (total === 0) { diff --git a/src/helpers/customForms.ts b/src/helpers/customForms.ts index 148e67f23..d3b650fb6 100644 --- a/src/helpers/customForms.ts +++ b/src/helpers/customForms.ts @@ -9,6 +9,7 @@ import { calculateTotals } from "@/components/extensive/WorkdayCollapseGrid/hook import { getCountriesOptions } from "@/constants/options/countries"; import { getMonthOptions } from "@/constants/options/months"; import { getCountriesStatesOptions } from "@/constants/options/states"; +import { Framework } from "@/context/framework.provider"; import { FormQuestionRead, FormRead, FormSectionRead } from "@/generated/apiSchemas"; import { Option } from "@/types/common"; import { urlValidation } from "@/utils/yup"; @@ -161,7 +162,7 @@ export const apiFormQuestionToFormField = ( entity?: Entity, feedbackRequired?: boolean ): FormField | null => { - const validation = getFieldValidation(question, t); + const validation = getFieldValidation(question, t, (entity?.framework_key as Framework) ?? Framework.UNKNOWN); const required = question.validation?.required || false; const sharedProps = { name: question.uuid, @@ -541,7 +542,7 @@ const getOptions = (question: FormQuestionRead, t: typeof useT) => { return options; }; -const getFieldValidation = (question: FormQuestionRead, t: typeof useT): AnySchema | null => { +const getFieldValidation = (question: FormQuestionRead, t: typeof useT, framework: Framework): AnySchema | null => { let validation; const required = question.validation?.required || false; const max = question.validation?.max; @@ -643,7 +644,7 @@ const getFieldValidation = (question: FormQuestionRead, t: typeof useT): AnySche const { demographics } = value.length > 0 ? value[0] : {}; if (demographics == null) return true; - return calculateTotals(demographics).countsMatch; + return calculateTotals(demographics, framework).complete; } ); From cc40d0692d1efaddf91b7de4b0758e15edacdf3d Mon Sep 17 00:00:00 2001 From: cesarLima1 <105736261+cesarLima1@users.noreply.github.com> Date: Tue, 29 Oct 2024 15:16:05 -0400 Subject: [PATCH 089/102] [TM-1407] change from project to organization (#600) --- src/pages/dashboard/components/SecDashboard.tsx | 2 +- src/pages/dashboard/hooks/useDashboardData.ts | 4 ++-- src/pages/dashboard/index.page.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/dashboard/components/SecDashboard.tsx b/src/pages/dashboard/components/SecDashboard.tsx index 257063041..1055a3351 100644 --- a/src/pages/dashboard/components/SecDashboard.tsx +++ b/src/pages/dashboard/components/SecDashboard.tsx @@ -65,7 +65,7 @@ const SecDashboard = ({ const tableColumns = [ { - header: isTableProject ? "Project" : "Specie", + header: isTableProject ? "Organization" : "Specie", accessorKey: "label", enableSorting: false }, diff --git a/src/pages/dashboard/hooks/useDashboardData.ts b/src/pages/dashboard/hooks/useDashboardData.ts index c4c120856..4e7429705 100644 --- a/src/pages/dashboard/hooks/useDashboardData.ts +++ b/src/pages/dashboard/hooks/useDashboardData.ts @@ -125,8 +125,8 @@ export const useDashboardData = (filters: any) => { useEffect(() => { if (topData?.data) { const projects = topData.data.top_projects_most_planted_trees.slice(0, 5); - const tableData = projects.map((project: { project: string; trees_planted: number }) => ({ - label: project.project, + const tableData = projects.map((project: { organization: string; project: string; trees_planted: number }) => ({ + label: project.organization, valueText: project.trees_planted.toLocaleString("en-US"), value: project.trees_planted })); diff --git a/src/pages/dashboard/index.page.tsx b/src/pages/dashboard/index.page.tsx index 10947117d..5d3937b70 100644 --- a/src/pages/dashboard/index.page.tsx +++ b/src/pages/dashboard/index.page.tsx @@ -350,7 +350,7 @@ const Dashboard = () => { /> Date: Tue, 29 Oct 2024 16:56:13 -0400 Subject: [PATCH 090/102] [TM-1319] Add HotJar and GoogleAnalytics to the Dashboard (#601) * [TM-1319] Add HotJar and GoogleAnalytics to the Dashboard * [TM-1319] Add HotJar and GoogleAnalytics to the Dashboard --- src/pages/_app.tsx | 10 +++-- .../dashboard/DashboardAnalyticsWrapper.tsx | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 src/pages/dashboard/DashboardAnalyticsWrapper.tsx diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index e823f7905..5d84074af 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -30,6 +30,8 @@ import { wrapper } from "@/store/store"; import Log from "@/utils/log"; import setupYup from "@/yup.locale"; +import DashboardAnalyticsWrapper from "./dashboard/DashboardAnalyticsWrapper"; + const CookieBanner = dynamic(() => import("@/components/extensive/CookieBanner/CookieBanner"), { ssr: false }); @@ -75,9 +77,11 @@ const _App = ({ Component, ...rest }: AppProps) => { - - - + + + + + diff --git a/src/pages/dashboard/DashboardAnalyticsWrapper.tsx b/src/pages/dashboard/DashboardAnalyticsWrapper.tsx new file mode 100644 index 000000000..812d18260 --- /dev/null +++ b/src/pages/dashboard/DashboardAnalyticsWrapper.tsx @@ -0,0 +1,41 @@ +import Script from "next/script"; +import React from "react"; + +interface DashboardAnalyticsWrapperProps { + children: React.ReactNode; +} + +const DashboardAnalyticsWrapper = ({ children }: DashboardAnalyticsWrapperProps) => { + return ( + <> + {/* Hotjar Script */} + + + {/* Google Analytics Script */} + + + {children} + + ); +}; + +export default DashboardAnalyticsWrapper; From 9bc6dd59f723e2addad5db4d6b6d5e3fdf1b3df5 Mon Sep 17 00:00:00 2001 From: JORGE Date: Tue, 29 Oct 2024 17:12:34 -0400 Subject: [PATCH 091/102] [TM-1354] fix loaders --- .../Inputs/FileInput/constants/subtitleMapOnUploaded.ts | 4 ++-- .../elements/Map-mapbox/MapControls/CheckPolygonControl.tsx | 3 +++ src/pages/site/[uuid]/tabs/Overview.tsx | 5 +++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/elements/Inputs/FileInput/constants/subtitleMapOnUploaded.ts b/src/components/elements/Inputs/FileInput/constants/subtitleMapOnUploaded.ts index 294c2be0e..6fb7868c1 100644 --- a/src/components/elements/Inputs/FileInput/constants/subtitleMapOnUploaded.ts +++ b/src/components/elements/Inputs/FileInput/constants/subtitleMapOnUploaded.ts @@ -1,4 +1,4 @@ export const SUBTITLE_MAP_ON_UPLOADED = { - image: "Image uploaded successfully!", - geoFile: "Data uploaded successfully!" + image: "Image is ready to be uploaded!", + geoFile: "Data is ready to be uploaded!" }; diff --git a/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx b/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx index 9c85f574c..52d69c192 100644 --- a/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx +++ b/src/components/elements/Map-mapbox/MapControls/CheckPolygonControl.tsx @@ -117,12 +117,14 @@ const CheckPolygonControl = (props: CheckSitePolygonProps) => { .filter(Boolean) .join(", "); openNotification("success", t("Success! The following polygons have been fixed:"), updatedPolygonNames); + hideLoader(); } closeModal(ModalId.FIX_POLYGONS); }, onError: error => { Log.error("Error clipping polygons:", error); displayNotification(t("An error occurred while fixing polygons. Please try again."), "error", t("Error")); + hideLoader(); } }); @@ -165,6 +167,7 @@ const CheckPolygonControl = (props: CheckSitePolygonProps) => { const runFixPolygonOverlaps = () => { if (siteUuid) { + showLoader(); clipPolygons({ pathParams: { uuid: siteUuid } }); } else { displayNotification(t("Cannot fix polygons: Site UUID is missing."), "error", t("Error")); diff --git a/src/pages/site/[uuid]/tabs/Overview.tsx b/src/pages/site/[uuid]/tabs/Overview.tsx index e1638ead6..5875b070c 100644 --- a/src/pages/site/[uuid]/tabs/Overview.tsx +++ b/src/pages/site/[uuid]/tabs/Overview.tsx @@ -115,7 +115,6 @@ const SiteOverviewTab = ({ site, refetch: refetchEntity }: SiteOverviewTabProps) uploadFiles(); setSaveFlags(false); closeModal(ModalId.ADD_POLYGONS); - hideLoader(); } }, [files, saveFlags]); @@ -137,7 +136,7 @@ const SiteOverviewTab = ({ site, refetch: refetchEntity }: SiteOverviewTabProps) formData.append("polygon_loaded", polygonLoaded.toString()); formData.append("submit_polygon_loaded", submitPolygonLoaded.toString()); let newRequest: any = formData; - + showLoader(); switch (fileType) { case "geojson": uploadPromises.push(fetchPostV2TerrafundUploadGeojson({ body: newRequest })); @@ -174,6 +173,8 @@ const SiteOverviewTab = ({ site, refetch: refetchEntity }: SiteOverviewTabProps) } else { openNotification("error", t("Error uploading file"), t("An unknown error occurred")); } + } finally { + hideLoader(); } }; From d7d243fd373ed1675bd1359a6f6df8b0a2d25e5e Mon Sep 17 00:00:00 2001 From: Ismael Martinez Date: Tue, 29 Oct 2024 20:27:31 -0600 Subject: [PATCH 092/102] fix: HBF Workday Implementation corrections --- .../extensive/WorkdayCollapseGrid/hooks.ts | 16 +++++++--------- .../extensive/WorkdayCollapseGrid/types.ts | 4 ++-- src/constants/options/age.ts | 4 ++-- src/helpers/customForms.ts | 2 +- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/components/extensive/WorkdayCollapseGrid/hooks.ts b/src/components/extensive/WorkdayCollapseGrid/hooks.ts index b373dc828..22dde4083 100644 --- a/src/components/extensive/WorkdayCollapseGrid/hooks.ts +++ b/src/components/extensive/WorkdayCollapseGrid/hooks.ts @@ -72,14 +72,12 @@ export function calculateTotals(demographics: Demographic[], framework: Framewor let total: number = 0; let complete: boolean = false; - if (counts) { - if (isHBFDemographicCounts(counts, framework)) { - total = counts.gender; - complete = counts.gender > 0; - } else { - total = Math.max(counts.age, counts.gender, counts.ethnicity); - complete = uniq([counts.age, counts.gender, counts.ethnicity]).length === 1; - } + if (isHBFDemographicCounts(counts, framework)) { + total = counts.gender; + complete = counts.gender > 0; + } else { + total = Math.max(counts.age, counts.gender, counts.ethnicity); + complete = uniq([counts.age, counts.gender, counts.ethnicity]).length === 1; } return { counts, total, complete }; @@ -100,7 +98,7 @@ export function useTableStatus(demographics: Demographic[]): { total: number; st return { total, status }; }, - [demographics] + [demographics, framework] ); } diff --git a/src/components/extensive/WorkdayCollapseGrid/types.ts b/src/components/extensive/WorkdayCollapseGrid/types.ts index b71ae909c..70e4ea1c4 100644 --- a/src/components/extensive/WorkdayCollapseGrid/types.ts +++ b/src/components/extensive/WorkdayCollapseGrid/types.ts @@ -50,8 +50,8 @@ const CASTES: Dictionary = { }; const AGES: Dictionary = { - youth: "Youth (15-24)", - adult: "Adult (24-64)", + youth: "Youth (15-29)", + adult: "Adult (29-64)", elder: "Elder (65+)", unknown: "Unknown" }; diff --git a/src/constants/options/age.ts b/src/constants/options/age.ts index 09e65697d..8ac51a1a8 100644 --- a/src/constants/options/age.ts +++ b/src/constants/options/age.ts @@ -4,11 +4,11 @@ import { Option } from "@/types/common"; export const getAgeOptions = (t: typeof useT = (t: string) => t): Option[] => [ { - title: t("Youth (15-24)"), + title: t("Youth (15-29)"), value: "youth" }, { - title: t("Adult (24-65)"), + title: t("Adult (29-64)"), value: "adult" }, { diff --git a/src/helpers/customForms.ts b/src/helpers/customForms.ts index d3b650fb6..a3c0ca36a 100644 --- a/src/helpers/customForms.ts +++ b/src/helpers/customForms.ts @@ -644,7 +644,7 @@ const getFieldValidation = (question: FormQuestionRead, t: typeof useT, framewor const { demographics } = value.length > 0 ? value[0] : {}; if (demographics == null) return true; - return calculateTotals(demographics, framework).complete; + return calculateTotals(demographics, framework).counts; } ); From 98274cec4eb95fde080688a5aad8302d3f22c1bf Mon Sep 17 00:00:00 2001 From: cesarLima1 <105736261+cesarLima1@users.noreply.github.com> Date: Wed, 30 Oct 2024 10:16:03 -0400 Subject: [PATCH 093/102] [TM-1407] rename table from organization to project (#604) --- src/pages/dashboard/index.page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/dashboard/index.page.tsx b/src/pages/dashboard/index.page.tsx index 5d3937b70..93e354e08 100644 --- a/src/pages/dashboard/index.page.tsx +++ b/src/pages/dashboard/index.page.tsx @@ -350,7 +350,7 @@ const Dashboard = () => { /> Date: Wed, 30 Oct 2024 08:53:44 -0700 Subject: [PATCH 094/102] Provide redux context in modal react roots. --- .../Map-mapbox/components/AdminPopup.tsx | 53 ++++++------ .../Map-mapbox/components/DashboardPopup.tsx | 10 ++- .../Map-mapbox/components/MediaPopup.tsx | 82 ++++++++++--------- 3 files changed, 79 insertions(+), 66 deletions(-) diff --git a/src/components/elements/Map-mapbox/components/AdminPopup.tsx b/src/components/elements/Map-mapbox/components/AdminPopup.tsx index f27b8d695..ea84dc060 100644 --- a/src/components/elements/Map-mapbox/components/AdminPopup.tsx +++ b/src/components/elements/Map-mapbox/components/AdminPopup.tsx @@ -1,4 +1,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Provider as ReduxProvider } from "react-redux"; + +import ApiSlice from "@/store/apiSlice"; import TooltipMap from "../../TooltipMap/TooltipMap"; @@ -8,29 +11,31 @@ export const AdminPopup = (event: any) => { const { feature, popup, setPolygonFromMap, type, setEditPolygon } = event; const uuidPolygon = feature.properties?.uuid; return ( - - { - if (popup) { - popup.remove(); - setPolygonFromMap?.({ isOpen: false, uuid: "" }); - setEditPolygon?.({ isOpen: false, uuid: "" }); - } - }} - setEditPolygon={(primary_uuid?: string) => { - setPolygonFromMap?.({ isOpen: true, uuid: uuidPolygon }); - setEditPolygon?.({ - isOpen: true, - uuid: uuidPolygon, - primary_uuid: primary_uuid - }); - if (popup) { - popup.remove(); - } - }} - /> - + + + { + if (popup) { + popup.remove(); + setPolygonFromMap?.({ isOpen: false, uuid: "" }); + setEditPolygon?.({ isOpen: false, uuid: "" }); + } + }} + setEditPolygon={(primary_uuid?: string) => { + setPolygonFromMap?.({ isOpen: true, uuid: uuidPolygon }); + setEditPolygon?.({ + isOpen: true, + uuid: uuidPolygon, + primary_uuid: primary_uuid + }); + if (popup) { + popup.remove(); + } + }} + /> + + ); }; diff --git a/src/components/elements/Map-mapbox/components/DashboardPopup.tsx b/src/components/elements/Map-mapbox/components/DashboardPopup.tsx index aacddf7d7..8fbba3428 100644 --- a/src/components/elements/Map-mapbox/components/DashboardPopup.tsx +++ b/src/components/elements/Map-mapbox/components/DashboardPopup.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useEffect, useState } from "react"; +import { Provider as ReduxProvider } from "react-redux"; import { LAYERS_NAMES } from "@/constants/layers"; import { @@ -8,6 +9,7 @@ import { fetchGetV2DashboardTotalSectionHeaderCountry } from "@/generated/apiComponents"; import TooltipGridMap from "@/pages/dashboard/components/TooltipGridMap"; +import ApiSlice from "@/store/apiSlice"; import { createQueryParams } from "@/utils/dashboardUtils"; import Log from "@/utils/log"; @@ -113,8 +115,10 @@ export const DashboardPopup = (event: any) => { } }, [isoCountry, layerName, itemUuid]); return ( - - - + + + + + ); }; diff --git a/src/components/elements/Map-mapbox/components/MediaPopup.tsx b/src/components/elements/Map-mapbox/components/MediaPopup.tsx index 640ac39d5..8cfc11c1c 100644 --- a/src/components/elements/Map-mapbox/components/MediaPopup.tsx +++ b/src/components/elements/Map-mapbox/components/MediaPopup.tsx @@ -1,9 +1,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useT } from "@transifex/react"; import { useState } from "react"; +import { Provider as ReduxProvider } from "react-redux"; import Text from "@/components/elements/Text/Text"; import Icon, { IconNames } from "@/components/extensive/Icon/Icon"; +import ApiSlice from "@/store/apiSlice"; import ImagePreview from "../../ImageGallery/ImagePreview"; import ImageWithPlaceholder from "../../ImageWithPlaceholder/ImageWithPlaceholder"; @@ -73,48 +75,50 @@ export const MediaPopup = ({ return ( <> - -
setOpenModal(!openModal)}> -
- -
- + + +
setOpenModal(!openModal)}> +
+ +
+ -
-
- - {name} - - - {new Date(created_date).toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - timeZone: "UTC" - })} - +
+
+ + {name} + + + {new Date(created_date).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + timeZone: "UTC" + })} + +
+ + {" "} +
- - {" "} -
-
- + + Date: Wed, 30 Oct 2024 11:41:41 -0600 Subject: [PATCH 095/102] fix: add hbf youth --- src/components/extensive/WorkdayCollapseGrid/types.ts | 8 +++++--- src/constants/options/age.ts | 4 ++-- src/helpers/customForms.ts | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/extensive/WorkdayCollapseGrid/types.ts b/src/components/extensive/WorkdayCollapseGrid/types.ts index 70e4ea1c4..5a92fcc38 100644 --- a/src/components/extensive/WorkdayCollapseGrid/types.ts +++ b/src/components/extensive/WorkdayCollapseGrid/types.ts @@ -50,13 +50,15 @@ const CASTES: Dictionary = { }; const AGES: Dictionary = { - youth: "Youth (15-29)", - adult: "Adult (29-64)", + youth: "Youth (15-24)", + adult: "Adult (24-64)", elder: "Elder (65+)", unknown: "Unknown" }; -const HBF_AGES = Object.fromEntries(Object.entries(AGES).filter(([key]) => key === "youth")); +const HBF_AGES: Dictionary = { + youth: "Youth (15-29)" +}; const ETHNICITIES: Dictionary = { indigenous: "Indigenous", diff --git a/src/constants/options/age.ts b/src/constants/options/age.ts index 8ac51a1a8..09e65697d 100644 --- a/src/constants/options/age.ts +++ b/src/constants/options/age.ts @@ -4,11 +4,11 @@ import { Option } from "@/types/common"; export const getAgeOptions = (t: typeof useT = (t: string) => t): Option[] => [ { - title: t("Youth (15-29)"), + title: t("Youth (15-24)"), value: "youth" }, { - title: t("Adult (29-64)"), + title: t("Adult (24-65)"), value: "adult" }, { diff --git a/src/helpers/customForms.ts b/src/helpers/customForms.ts index a3c0ca36a..d3b650fb6 100644 --- a/src/helpers/customForms.ts +++ b/src/helpers/customForms.ts @@ -644,7 +644,7 @@ const getFieldValidation = (question: FormQuestionRead, t: typeof useT, framewor const { demographics } = value.length > 0 ? value[0] : {}; if (demographics == null) return true; - return calculateTotals(demographics, framework).counts; + return calculateTotals(demographics, framework).complete; } ); From 951a18addab8105d94e981daac887fb9742b0245 Mon Sep 17 00:00:00 2001 From: cesarLima1 <105736261+cesarLima1@users.noreply.github.com> Date: Wed, 30 Oct 2024 15:01:05 -0400 Subject: [PATCH 096/102] [TM-1366] change values in landscape options (#606) --- src/pages/dashboard/components/HeaderDashboard.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/dashboard/components/HeaderDashboard.tsx b/src/pages/dashboard/components/HeaderDashboard.tsx index ee0900689..d9f49983f 100644 --- a/src/pages/dashboard/components/HeaderDashboard.tsx +++ b/src/pages/dashboard/components/HeaderDashboard.tsx @@ -70,9 +70,9 @@ const HeaderDashboard = (props: HeaderDashboardProps) => { ]; const landscapeOption = [ - { title: "Kenya’s Greater Rift Valley", value: "kenya_greater_rift_valley" }, - { title: "Ghana Cocoa Belt ", value: "ghana_cocoa_belt" }, - { title: "Lake Kivu and Rusizi River Basin ", value: "lake_kivu_rusizi_river_basin" } + { title: "Greater Rift Valley of Kenya", value: "Greater Rift Valley of Kenya" }, + { title: "Ghana Cocoa Belt ", value: "Ghana Cocoa Belt" }, + { title: "Lake Kivu & Rusizi River Basin ", value: "Lake Kivu & Rusizi River Basin" } ]; const { data: frameworks } = useGetV2DashboardFrameworks({ From c79f3a9c6a8a612df4142c0db82a383625ab1a8f Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 30 Oct 2024 16:39:11 -0700 Subject: [PATCH 097/102] [TM-1415] Provide the framework to getCustomFormSteps when possible. --- .../components/EntityEdit/EntityEdit.tsx | 10 +++--- .../ChangeRequestsTab/ChangeRequestsTab.tsx | 4 ++- .../ResourceTabs/InformationTab/index.tsx | 5 +-- .../components/ApplicationTabs.tsx | 4 ++- src/helpers/customForms.ts | 32 +++++++++++++------ .../useGetCustomFormSteps.ts | 4 ++- .../edit/[uuid]/EditEntityForm.tsx | 3 ++ 7 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/admin/components/EntityEdit/EntityEdit.tsx b/src/admin/components/EntityEdit/EntityEdit.tsx index 252b2e94d..a81045de0 100644 --- a/src/admin/components/EntityEdit/EntityEdit.tsx +++ b/src/admin/components/EntityEdit/EntityEdit.tsx @@ -5,7 +5,7 @@ import { useNavigate, useParams } from "react-router-dom"; import modules from "@/admin/modules"; import WizardForm from "@/components/extensive/WizardForm"; import LoadingContainer from "@/components/generic/Loading/LoadingContainer"; -import FrameworkProvider from "@/context/framework.provider"; +import FrameworkProvider, { Framework } from "@/context/framework.provider"; import { GetV2FormsENTITYUUIDResponse, useGetV2FormsENTITYUUID, @@ -49,10 +49,12 @@ export const EntityEdit = () => { // @ts-ignore const formData = (formResponse?.data ?? {}) as GetV2FormsENTITYUUIDResponse; - const formSteps = useGetCustomFormSteps(formData.form, { + const entity = { entityName: pluralEntityNameToSingular(entityName), entityUUID - }); + }; + const framework = formData?.form?.framework_key as Framework; + const formSteps = useGetCustomFormSteps(formData.form, entity, framework); const defaultValues = useNormalizedFormDefaultValue( // @ts-ignore @@ -70,7 +72,7 @@ export const EntityEdit = () => { return (
- + = ({ label, entity, singularEntity, ...rest // @ts-ignore const changeRequest = currentValues?.data?.update_request; + const framework = changeRequest?.framework_key as Framework; const changes = changeRequest?.content; // @ts-ignore const current = currentValues?.data?.answers; @@ -54,7 +56,7 @@ const ChangeRequestsTab: FC = ({ label, entity, singularEntity, ...rest // @ts-ignore const form = currentValues?.data?.form; - const formSteps = useMemo(() => (form == null ? [] : getCustomFormSteps(form, t)), [form, t]); + const formSteps = useMemo(() => (form == null ? [] : getCustomFormSteps(form, t, undefined, framework)), [form, t]); const formChanges = useFormChanges(current, changes, formSteps ?? []); const numFieldsAffected = useMemo( () => diff --git a/src/admin/components/ResourceTabs/InformationTab/index.tsx b/src/admin/components/ResourceTabs/InformationTab/index.tsx index 1b5194244..213d5428e 100644 --- a/src/admin/components/ResourceTabs/InformationTab/index.tsx +++ b/src/admin/components/ResourceTabs/InformationTab/index.tsx @@ -75,7 +75,8 @@ const InformationTab: FC = props => { if (isLoading) return null; - const formSteps = getCustomFormSteps(response?.data.form!, t); + const framework = record.framework_key as Framework; + const formSteps = getCustomFormSteps(response?.data.form!, t, undefined, framework); const values = record.migrated ? setDefaultConditionalFieldsAnswers(normalizedFormDefaultValue(response?.data.answers!, formSteps), formSteps) @@ -101,7 +102,7 @@ const InformationTab: FC = props => { })(); return ( - + diff --git a/src/admin/modules/application/components/ApplicationTabs.tsx b/src/admin/modules/application/components/ApplicationTabs.tsx index f57692acd..f28c97735 100644 --- a/src/admin/modules/application/components/ApplicationTabs.tsx +++ b/src/admin/modules/application/components/ApplicationTabs.tsx @@ -13,6 +13,7 @@ import { Else, If, Then, When } from "react-if"; import { formatEntryValue } from "@/admin/apiProvider/utils/entryFormat"; import List from "@/components/extensive/List/List"; import { FormSummaryRowProps, useGetFormEntries } from "@/components/extensive/WizardForm/FormSummaryRow"; +import { Framework } from "@/context/framework.provider"; import { ApplicationRead, FormSubmissionRead } from "@/generated/apiSchemas"; import { getCustomFormSteps, normalizedFormDefaultValue } from "@/helpers/customForms"; import { Entity } from "@/types/common"; @@ -48,7 +49,8 @@ const ApplicationTabRow = ({ index, ...props }: FormSummaryRowProps) => { const ApplicationTab = ({ record }: { record: FormSubmissionRead }) => { const t = useT(); - const formSteps = getCustomFormSteps(record?.form!, t); + const framework = record?.form?.framework_key as Framework; + const formSteps = getCustomFormSteps(record?.form!, t, undefined, framework); const values = normalizedFormDefaultValue(record?.answers, formSteps); const currentPitchEntity: Entity = { entityName: "project-pitches", diff --git a/src/helpers/customForms.ts b/src/helpers/customForms.ts index d3b650fb6..98bebe146 100644 --- a/src/helpers/customForms.ts +++ b/src/helpers/customForms.ts @@ -11,7 +11,7 @@ import { getMonthOptions } from "@/constants/options/months"; import { getCountriesStatesOptions } from "@/constants/options/states"; import { Framework } from "@/context/framework.provider"; import { FormQuestionRead, FormRead, FormSectionRead } from "@/generated/apiSchemas"; -import { Option } from "@/types/common"; +import { Entity, Option } from "@/types/common"; import { urlValidation } from "@/utils/yup"; export function normalizedFormData(values: T, steps: FormStepSchema[]): T { @@ -126,10 +126,11 @@ export const getCustomFormSteps = ( schema: FormRead, t: typeof useT, entity?: Entity, + framework?: Framework, feedback_fields?: string[] ): FormStepSchema[] => { return sortBy(schema.form_sections, ["order"]).map(section => - apiFormSectionToFormStep(section, t, entity, feedback_fields) + apiFormSectionToFormStep(section, t, entity, framework, feedback_fields) ); }; @@ -137,20 +138,27 @@ export const apiFormSectionToFormStep = ( section: FormSectionRead, t: typeof useT, entity?: Entity, + framework?: Framework, feedback_fields?: string[] ): FormStepSchema => { return { title: section.title, subtitle: section.description, - fields: apiQuestionsToFormFields(section.form_questions, t, entity, feedback_fields) + fields: apiQuestionsToFormFields(section.form_questions, t, entity, framework, feedback_fields) }; }; -export const apiQuestionsToFormFields = (questions: any, t: typeof useT, entity?: Entity, feedback_fields?: string[]) => +export const apiQuestionsToFormFields = ( + questions: any, + t: typeof useT, + entity?: Entity, + framework?: Framework, + feedback_fields?: string[] +) => sortBy(questions, "order") .map((question, index, array) => { const feedbackRequired = feedback_fields?.includes(question.uuid); - return apiFormQuestionToFormField(question, t, index, array, entity, feedbackRequired); + return apiFormQuestionToFormField(question, t, index, array, entity, framework, feedbackRequired); }) .filter(field => !!field) as FormField[]; @@ -160,9 +168,10 @@ export const apiFormQuestionToFormField = ( index: number, questions: FormQuestionRead[], entity?: Entity, + framework?: Framework, feedbackRequired?: boolean ): FormField | null => { - const validation = getFieldValidation(question, t, (entity?.framework_key as Framework) ?? Framework.UNKNOWN); + const validation = getFieldValidation(question, t, framework ?? Framework.UNDEFINED); const required = question.validation?.required || false; const sharedProps = { name: question.uuid, @@ -305,7 +314,7 @@ export const apiFormQuestionToFormField = ( fieldProps: { required, headers: sortBy(question.table_headers, "order")?.map(h => h.label), - rows: sortBy(question.children, "order").map(q => apiFormQuestionToFormField(q, t, entity)), + rows: sortBy(question.children, "order").map(q => apiFormQuestionToFormField(q, t, entity, framework)), hasTotal: question.with_numbers } }; @@ -319,7 +328,7 @@ export const apiFormQuestionToFormField = ( fieldProps: { required, addButtonCaption: question.add_button_text, - fields: sortBy(question.children, "order").map(q => apiFormQuestionToFormField(q, t, entity)), + fields: sortBy(question.children, "order").map(q => apiFormQuestionToFormField(q, t, entity, framework)), tableColumns: sortBy(question.children, "order").map(q => ({ title: q.header_label, key: q.uuid })) } }; @@ -491,7 +500,7 @@ export const apiFormQuestionToFormField = ( required, id: question.uuid, inputId: question.uuid, - fields: apiQuestionsToFormFields(question.children, t, entity) + fields: apiQuestionsToFormFields(question.children, t, entity, framework) } }; @@ -639,7 +648,10 @@ const getFieldValidation = (question: FormQuestionRead, t: typeof useT, framewor ) .test( "totals-match", - () => "The totals for each demographic type do not match", + () => + framework === Framework.HBF + ? "At least one entry in gender is required" + : "The totals for each demographic type do not match", value => { const { demographics } = value.length > 0 ? value[0] : {}; if (demographics == null) return true; diff --git a/src/hooks/useGetCustomFormSteps/useGetCustomFormSteps.ts b/src/hooks/useGetCustomFormSteps/useGetCustomFormSteps.ts index 40265b1fc..9c7580d9c 100644 --- a/src/hooks/useGetCustomFormSteps/useGetCustomFormSteps.ts +++ b/src/hooks/useGetCustomFormSteps/useGetCustomFormSteps.ts @@ -2,6 +2,7 @@ import { useT } from "@transifex/react"; import { useMemo } from "react"; import { FormStepSchema } from "@/components/extensive/WizardForm/types"; +import { Framework } from "@/context/framework.provider"; import { FormRead } from "@/generated/apiSchemas"; import { getCustomFormSteps, normalizedFormDefaultValue } from "@/helpers/customForms"; import { Entity } from "@/types/common"; @@ -9,12 +10,13 @@ import { Entity } from "@/types/common"; export const useGetCustomFormSteps = ( schema?: FormRead, entity?: Entity, + framework?: Framework, feedback_fields?: string[] ): FormStepSchema[] | undefined => { const t = useT(); return useMemo( - () => (schema ? getCustomFormSteps(schema, t, entity, feedback_fields) : undefined), + () => (schema ? getCustomFormSteps(schema, t, entity, framework, feedback_fields) : undefined), // eslint-disable-next-line react-hooks/exhaustive-deps [schema, t] ); diff --git a/src/pages/entity/[entityName]/edit/[uuid]/EditEntityForm.tsx b/src/pages/entity/[entityName]/edit/[uuid]/EditEntityForm.tsx index 47658d87e..1e7eaa556 100644 --- a/src/pages/entity/[entityName]/edit/[uuid]/EditEntityForm.tsx +++ b/src/pages/entity/[entityName]/edit/[uuid]/EditEntityForm.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; import { useMemo } from "react"; import WizardForm from "@/components/extensive/WizardForm"; +import { useFrameworkContext } from "@/context/framework.provider"; import { GetV2FormsENTITYUUIDResponse, usePutV2FormsENTITYUUID, @@ -27,6 +28,7 @@ interface EditEntityFormProps { const EditEntityForm = ({ entityName, entityUUID, entity, formData }: EditEntityFormProps) => { const t = useT(); const router = useRouter(); + const { framework } = useFrameworkContext(); const mode = router.query.mode as string | undefined; //edit, provide-feedback-entity, provide-feedback-change-request const isReport = isEntityReport(entityName); @@ -50,6 +52,7 @@ const EditEntityForm = ({ entityName, entityUUID, entity, formData }: EditEntity entityName: pluralEntityNameToSingular(entityName), entityUUID }, + framework, mode?.includes("provide-feedback") ? feedbackFields : undefined ); From 439dc811dd34698bbf2d8f6c5bdaedfd75a131ef Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 31 Oct 2024 10:26:45 -0700 Subject: [PATCH 098/102] [TM-1415] Provide framework to all tabs on the entity show pages. --- .../ResourceTabs/InformationTab/index.tsx | 134 +++++++++--------- .../nurseries/components/NurseryShow.tsx | 17 ++- .../components/NurseryReportShow.tsx | 17 ++- .../components/ProjectReportShow.tsx | 17 ++- .../projects/components/ProjectShow.tsx | 19 +-- .../siteReports/components/SiteReportShow.tsx | 17 ++- .../modules/sites/components/SiteShow.tsx | 29 ++-- src/context/framework.provider.tsx | 6 + 8 files changed, 139 insertions(+), 117 deletions(-) diff --git a/src/admin/components/ResourceTabs/InformationTab/index.tsx b/src/admin/components/ResourceTabs/InformationTab/index.tsx index 213d5428e..a0ba734ab 100644 --- a/src/admin/components/ResourceTabs/InformationTab/index.tsx +++ b/src/admin/components/ResourceTabs/InformationTab/index.tsx @@ -11,7 +11,7 @@ import SeedingsTable from "@/admin/components/Tables/SeedingsTable"; import { setDefaultConditionalFieldsAnswers } from "@/admin/utils/forms"; import List from "@/components/extensive/List/List"; import { ContextCondition } from "@/context/ContextCondition"; -import FrameworkProvider, { Framework } from "@/context/framework.provider"; +import { Framework } from "@/context/framework.provider"; import { GetV2FormsENTITYUUIDResponse, useGetV2FormsENTITYUUID, @@ -102,84 +102,82 @@ const InformationTab: FC = props => { })(); return ( - - - - - - - - - - Nothing to Report - - - The project has indicated that there is no activity to report on for this{" "} - {pluralEntityNameToSingular(props.type).split("-")[0]} during this reporting period. - + + + + + + + + + Nothing to Report + + + The project has indicated that there is no activity to report on for this{" "} + {pluralEntityNameToSingular(props.type).split("-")[0]} during this reporting period. + + + + + + + ( + + )} + /> - - - - - ( - - )} - /> - - - - - - - Total Trees Planted - - {record?.total_trees_planted_count} - - - - - - + - Total Seeds Planted + Total Trees Planted - {totalSeedlings} + {record?.total_trees_planted_count} - + + + + + + + + Total Seeds Planted + + {totalSeedlings} + + + + + + + + + + + + + - - - - - - - - - - - - + + - - - + + + ); }; diff --git a/src/admin/modules/nurseries/components/NurseryShow.tsx b/src/admin/modules/nurseries/components/NurseryShow.tsx index a585c616c..0e2c9d6ce 100644 --- a/src/admin/modules/nurseries/components/NurseryShow.tsx +++ b/src/admin/modules/nurseries/components/NurseryShow.tsx @@ -9,6 +9,7 @@ import DocumentTab from "@/admin/components/ResourceTabs/DocumentTab/DocumentTab import GalleryTab from "@/admin/components/ResourceTabs/GalleryTab/GalleryTab"; import InformationTab from "@/admin/components/ResourceTabs/InformationTab"; import ShowTitle from "@/admin/components/ShowTitle"; +import { RecordFrameworkProvider } from "@/context/framework.provider"; const NurseryShow: FC = () => { return ( @@ -17,13 +18,15 @@ const NurseryShow: FC = () => { actions={} className="-mt-[50px] bg-neutral-100" > - - - - - - - + + + + + + + + + ); }; diff --git a/src/admin/modules/nurseryReports/components/NurseryReportShow.tsx b/src/admin/modules/nurseryReports/components/NurseryReportShow.tsx index a481d2d35..c21797ef5 100644 --- a/src/admin/modules/nurseryReports/components/NurseryReportShow.tsx +++ b/src/admin/modules/nurseryReports/components/NurseryReportShow.tsx @@ -9,6 +9,7 @@ import DocumentTab from "@/admin/components/ResourceTabs/DocumentTab/DocumentTab import GalleryTab from "@/admin/components/ResourceTabs/GalleryTab/GalleryTab"; import InformationTab from "@/admin/components/ResourceTabs/InformationTab"; import ShowTitle from "@/admin/components/ShowTitle"; +import { RecordFrameworkProvider } from "@/context/framework.provider"; const NurseryReportShow: FC = () => { return ( @@ -17,13 +18,15 @@ const NurseryReportShow: FC = () => { actions={} className="-mt-[50px] bg-neutral-100" > - - - - - - - + + + + + + + + + ); }; diff --git a/src/admin/modules/projectReports/components/ProjectReportShow.tsx b/src/admin/modules/projectReports/components/ProjectReportShow.tsx index 1ed12a858..abad8a533 100644 --- a/src/admin/modules/projectReports/components/ProjectReportShow.tsx +++ b/src/admin/modules/projectReports/components/ProjectReportShow.tsx @@ -9,6 +9,7 @@ import DocumentTab from "@/admin/components/ResourceTabs/DocumentTab/DocumentTab import GalleryTab from "@/admin/components/ResourceTabs/GalleryTab/GalleryTab"; import InformationTab from "@/admin/components/ResourceTabs/InformationTab"; import ShowTitle from "@/admin/components/ShowTitle"; +import { RecordFrameworkProvider } from "@/context/framework.provider"; const ProjectReportShow: FC = () => { return ( @@ -17,13 +18,15 @@ const ProjectReportShow: FC = () => { actions={} className="-mt-[50px] bg-neutral-100" > - - - - - - - + + + + + + + + + ); }; diff --git a/src/admin/modules/projects/components/ProjectShow.tsx b/src/admin/modules/projects/components/ProjectShow.tsx index 1aa082c02..65b48779c 100644 --- a/src/admin/modules/projects/components/ProjectShow.tsx +++ b/src/admin/modules/projects/components/ProjectShow.tsx @@ -9,6 +9,7 @@ import DocumentTab from "@/admin/components/ResourceTabs/DocumentTab/DocumentTab import GalleryTab from "@/admin/components/ResourceTabs/GalleryTab/GalleryTab"; import InformationTab from "@/admin/components/ResourceTabs/InformationTab"; import ShowTitle from "@/admin/components/ShowTitle"; +import { RecordFrameworkProvider } from "@/context/framework.provider"; const ProjectShow: FC = () => { return ( @@ -17,14 +18,16 @@ const ProjectShow: FC = () => { actions={} className="-mt-[50px] bg-neutral-100" > - }> - - - - - In Progress - - + + }> + + + + + In Progress + + + ); }; diff --git a/src/admin/modules/siteReports/components/SiteReportShow.tsx b/src/admin/modules/siteReports/components/SiteReportShow.tsx index 4434b82d4..07b8e1f8e 100644 --- a/src/admin/modules/siteReports/components/SiteReportShow.tsx +++ b/src/admin/modules/siteReports/components/SiteReportShow.tsx @@ -9,6 +9,7 @@ import DocumentTab from "@/admin/components/ResourceTabs/DocumentTab/DocumentTab import GalleryTab from "@/admin/components/ResourceTabs/GalleryTab/GalleryTab"; import InformationTab from "@/admin/components/ResourceTabs/InformationTab"; import ShowTitle from "@/admin/components/ShowTitle"; +import { RecordFrameworkProvider } from "@/context/framework.provider"; const SiteReportShow: FC = () => { return ( @@ -17,13 +18,15 @@ const SiteReportShow: FC = () => { actions={} className="-mt-[50px] bg-neutral-100" > - - - - - - - + + + + + + + + + ); }; diff --git a/src/admin/modules/sites/components/SiteShow.tsx b/src/admin/modules/sites/components/SiteShow.tsx index c028aac4a..216c5e366 100644 --- a/src/admin/modules/sites/components/SiteShow.tsx +++ b/src/admin/modules/sites/components/SiteShow.tsx @@ -10,6 +10,7 @@ import GalleryTab from "@/admin/components/ResourceTabs/GalleryTab/GalleryTab"; import InformationTab from "@/admin/components/ResourceTabs/InformationTab"; import PolygonReviewTab from "@/admin/components/ResourceTabs/PolygonReviewTab"; import ShowTitle from "@/admin/components/ShowTitle"; +import { RecordFrameworkProvider } from "@/context/framework.provider"; import { MapAreaProvider } from "@/context/mapArea.provider"; const SiteShow: FC = () => { @@ -19,19 +20,21 @@ const SiteShow: FC = () => { actions={} className="-mt-[50px] bg-neutral-100" > - - - - - - - - - - - In Progress - - + + + + + + + + + + + + In Progress + + + ); }; diff --git a/src/context/framework.provider.tsx b/src/context/framework.provider.tsx index 6773f4fe7..70b7128a2 100644 --- a/src/context/framework.provider.tsx +++ b/src/context/framework.provider.tsx @@ -1,4 +1,5 @@ import { ComponentType, createContext, ReactNode, useContext, useMemo } from "react"; +import { useShowContext } from "react-admin"; export enum Framework { PPC = "ppc", @@ -64,4 +65,9 @@ export function withFrameworkShow(WrappedComponent: ComponentType) { return FrameworkShowHide; } +export function RecordFrameworkProvider({ children }: { children: ReactNode }) { + const { record } = useShowContext(); + return {children}; +} + export default FrameworkProvider; From b37dcd8d34b294135952330c218a1e9c68b5ad8e Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 31 Oct 2024 10:32:41 -0700 Subject: [PATCH 099/102] [TM-1415] Get the framework from context. --- src/admin/components/ResourceTabs/InformationTab/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/admin/components/ResourceTabs/InformationTab/index.tsx b/src/admin/components/ResourceTabs/InformationTab/index.tsx index a0ba734ab..c3263abcc 100644 --- a/src/admin/components/ResourceTabs/InformationTab/index.tsx +++ b/src/admin/components/ResourceTabs/InformationTab/index.tsx @@ -11,7 +11,7 @@ import SeedingsTable from "@/admin/components/Tables/SeedingsTable"; import { setDefaultConditionalFieldsAnswers } from "@/admin/utils/forms"; import List from "@/components/extensive/List/List"; import { ContextCondition } from "@/context/ContextCondition"; -import { Framework } from "@/context/framework.provider"; +import { Framework, useFrameworkContext } from "@/context/framework.provider"; import { GetV2FormsENTITYUUIDResponse, useGetV2FormsENTITYUUID, @@ -75,7 +75,7 @@ const InformationTab: FC = props => { if (isLoading) return null; - const framework = record.framework_key as Framework; + const { framework } = useFrameworkContext(); const formSteps = getCustomFormSteps(response?.data.form!, t, undefined, framework); const values = record.migrated From cc18e3fbbe04d263d8c81779911be3645e64ad92 Mon Sep 17 00:00:00 2001 From: Jorge Monroy Date: Thu, 31 Oct 2024 16:42:12 -0400 Subject: [PATCH 100/102] [TM-1429] bulk deletion (#612) * [TM-1429] send complete values selected in modal * [TM-1429] fix select all button * [TM-1429] display delete layer --- .../PolygonReviewTab/components/Polygons.tsx | 3 +- src/components/elements/Map-mapbox/Map.tsx | 4 +- .../ProcessBulkPolygonsControl.tsx | 51 ++++++++++--------- .../Modal/ModalProcessBulkPolygons.tsx | 25 +++++++-- src/helpers/customForms.ts | 2 +- 5 files changed, 52 insertions(+), 33 deletions(-) diff --git a/src/admin/components/ResourceTabs/PolygonReviewTab/components/Polygons.tsx b/src/admin/components/ResourceTabs/PolygonReviewTab/components/Polygons.tsx index 8d1afb211..945f1f30e 100644 --- a/src/admin/components/ResourceTabs/PolygonReviewTab/components/Polygons.tsx +++ b/src/admin/components/ResourceTabs/PolygonReviewTab/components/Polygons.tsx @@ -196,7 +196,8 @@ const Polygons = (props: IPolygonProps) => { return prevCheckedUuids.filter((id: string) => id !== uuid); } }; - setSelectedPolygonsInCheckbox(polygonsChecked); + const checkedUuids = polygonsChecked(selectedPolygonsInCheckbox); + setSelectedPolygonsInCheckbox(checkedUuids); }; return ( diff --git a/src/components/elements/Map-mapbox/Map.tsx b/src/components/elements/Map-mapbox/Map.tsx index 3e6cb4805..b3513c1b2 100644 --- a/src/components/elements/Map-mapbox/Map.tsx +++ b/src/components/elements/Map-mapbox/Map.tsx @@ -391,7 +391,7 @@ export const MapContainer = ({ } useEffect(() => { - if (selectedPolygonsInCheckbox && map.current && styleLoaded && map.current.isStyleLoaded()) { + if (selectedPolygonsInCheckbox && map.current && styleLoaded) { const newPolygonData = { [DELETED_POLYGONS]: selectedPolygonsInCheckbox }; @@ -401,7 +401,7 @@ export const MapContainer = ({ newPolygonData ); } - }, [selectedPolygonsInCheckbox]); + }, [selectedPolygonsInCheckbox, styleLoaded]); const handleEditPolygon = async () => { removePopups("POLYGON"); diff --git a/src/components/elements/Map-mapbox/MapControls/ProcessBulkPolygonsControl.tsx b/src/components/elements/Map-mapbox/MapControls/ProcessBulkPolygonsControl.tsx index a83ee4497..7aa998d04 100644 --- a/src/components/elements/Map-mapbox/MapControls/ProcessBulkPolygonsControl.tsx +++ b/src/components/elements/Map-mapbox/MapControls/ProcessBulkPolygonsControl.tsx @@ -39,7 +39,8 @@ const ProcessBulkPolygonsControl = ({ entityData }: { entityData: any }) => { const { mutate: fixPolygons } = usePostV2TerrafundClipPolygonsPolygons(); const sitePolygonData = context?.sitePolygonData as Array; const { mutate: deletePolygons } = useDeleteV2TerrafundProjectPolygons(); - const openFormModalHandlerProcessBulkPolygons = (selectedUUIDs: string[]) => { + + const openFormModalHandlerProcessBulkPolygons = () => { openModal( ModalId.DELETE_BULK_POLYGONS, { onClose={() => closeModal(ModalId.DELETE_BULK_POLYGONS)} content={t("Confirm that the following polygons will be deleted. This operation is not reversible.")} primaryButtonText={t("Delete")} - primaryButtonProps={{ - className: "px-8 py-3", - variant: "primary", - onClick: () => { - showLoader(); - closeModal(ModalId.DELETE_BULK_POLYGONS); - deletePolygons( - { - body: { - uuids: selectedUUIDs - } + onClick={(currentSelectedUuids: any) => { + showLoader(); + closeModal(ModalId.DELETE_BULK_POLYGONS); + deletePolygons( + { + body: { + uuids: currentSelectedUuids + } + }, + { + onSuccess: () => { + refetchData(); + hideLoader(); + openNotification("success", t("Success!"), t("Polygons deleted successfully")); }, - { - onSuccess: () => { - refetchData(); - hideLoader(); - openNotification("success", t("Success!"), t("Polygons deleted successfully")); - }, - onError: () => { - hideLoader(); - openNotification("error", t("Error!"), t("Failed to delete polygons")); - } + onError: () => { + hideLoader(); + openNotification("error", t("Error!"), t("Failed to delete polygons")); } - ); - } + } + ); + }} + primaryButtonProps={{ + className: "px-8 py-3", + variant: "primary" }} secondaryButtonText={t("Cancel")} secondaryButtonProps={{ @@ -174,7 +175,7 @@ const ProcessBulkPolygonsControl = ({ entityData }: { entityData: any }) => { } else if (type === "fix") { openFormModalHandlerSubmitPolygon(selectedUUIDs); } else { - openFormModalHandlerProcessBulkPolygons(selectedUUIDs); + openFormModalHandlerProcessBulkPolygons(); } }; diff --git a/src/components/extensive/Modal/ModalProcessBulkPolygons.tsx b/src/components/extensive/Modal/ModalProcessBulkPolygons.tsx index 4da225a1d..c72a791dd 100644 --- a/src/components/extensive/Modal/ModalProcessBulkPolygons.tsx +++ b/src/components/extensive/Modal/ModalProcessBulkPolygons.tsx @@ -19,6 +19,7 @@ export interface ModalDeleteBulkPolygonsProps extends ModalProps { sitePolygonData: SitePolygonsDataResponse; selectedPolygonsInCheckbox: string[]; refetch?: () => void; + onClick?: (currentSelectedUuids: any) => void; } const ModalProcessBulkPolygons: FC = ({ @@ -33,17 +34,20 @@ const ModalProcessBulkPolygons: FC = ({ onClose, sitePolygonData, selectedPolygonsInCheckbox, + onClick, refetch, ...rest }) => { const t = useT(); const [polygonsSelected, setPolygonsSelected] = useState([]); - + const [currentSelectedUuids, setCurrentSelectedUuids] = useState([]); + const [selectAll, setSelectAll] = useState(false); useEffect(() => { if (sitePolygonData) { const initialSelection = sitePolygonData.map((polygon: any) => selectedPolygonsInCheckbox.includes(polygon.poly_id) ); + setCurrentSelectedUuids(selectedPolygonsInCheckbox); setPolygonsSelected(initialSelection); } }, [sitePolygonData, selectedPolygonsInCheckbox]); @@ -52,13 +56,25 @@ const ModalProcessBulkPolygons: FC = ({ setPolygonsSelected(prev => { const newSelected = [...prev]; newSelected[index] = !prev[index]; + if (newSelected.every(Boolean)) { + setSelectAll(true); + } else { + setSelectAll(false); + } + const polygonUuid: string = sitePolygonData[index].poly_id as string; + if (newSelected[index]) { + setCurrentSelectedUuids([...currentSelectedUuids, polygonUuid]); + } else { + setCurrentSelectedUuids(currentSelectedUuids.filter(uuid => uuid !== polygonUuid)); + } return newSelected; }); }; const handleSelectAll = (isChecked: boolean) => { setPolygonsSelected(sitePolygonData.map(() => isChecked)); + setCurrentSelectedUuids(isChecked ? sitePolygonData.map(polygon => polygon.poly_id as string) : []); + setSelectAll(isChecked); }; - return (
@@ -87,7 +103,8 @@ const ModalProcessBulkPolygons: FC = ({ - {t("Select All")} handleSelectAll(e.target.checked)} /> + {t("Select All")}{" "} + handleSelectAll(e.target.checked)} />
@@ -118,7 +135,7 @@ const ModalProcessBulkPolygons: FC = ({ -