From 344536fb3b2a8987d5ea8160b36e554fd4dbfb6d Mon Sep 17 00:00:00 2001 From: Bartosz Butrym Date: Mon, 20 Jan 2025 15:38:16 +0100 Subject: [PATCH 1/6] change frontend builder from create-react-app to vite, adjust libraries and refactor code --- ui/.env.example | 2 +- ui/.gitignore | 24 + ui/.prettierrc | 5 +- ui/eslint.config.js | 35 + ui/index.html | 15 + ui/openapi-codegen.config.ts | 23 +- ui/package.json | 83 +- ui/public/index.html | 43 - ui/src/App.test.tsx | 8 +- ui/src/App.tsx | 11 +- ui/src/api/apiContext.ts | 23 +- ui/src/api/apiFetcher.ts | 70 +- .../components/ErrorMessage/ErrorMessage.tsx | 10 +- .../FeedbackButton/FeedbackButton.tsx | 32 +- .../FeedbackButton/useFormikValuesChanged.tsx | 10 +- ui/src/components/FormikInput/FormikInput.tsx | 15 +- .../TwoColumnHero/TwoColumnHero.tsx | 20 +- ui/src/components/index.ts | 8 +- ui/src/contexts/UserContext/User.context.ts | 13 + .../UserContext/UserContext.constants.ts | 7 + .../contexts/UserContext/UserContext.test.tsx | 136 +- ui/src/contexts/UserContext/UserContext.tsx | 50 +- ui/src/contexts/index.ts | 2 +- ui/src/index.tsx | 13 - ui/src/main.tsx | 11 + ui/src/main/Footer/Footer.test.tsx | 21 +- ui/src/main/Footer/Footer.tsx | 31 +- ui/src/main/ForkMe/ForkMe.test.tsx | 8 +- ui/src/main/ForkMe/ForkMe.tsx | 19 +- ui/src/main/Loader/Loader.test.tsx | 8 +- ui/src/main/Loader/Loader.tsx | 5 +- ui/src/main/Main/Main.test.tsx | 47 +- ui/src/main/Main/Main.tsx | 18 +- .../main/Main/useLocalStoragedApiKey.test.tsx | 88 +- ui/src/main/Main/useLocalStoragedApiKey.tsx | 24 +- ui/src/main/Main/useLoginOnApiKey.test.tsx | 114 +- ui/src/main/Main/useLoginOnApiKey.tsx | 21 +- ui/src/main/Routes/ProtectedRoute.test.tsx | 36 +- ui/src/main/Routes/ProtectedRoute.tsx | 14 +- ui/src/main/Routes/Routes.test.tsx | 60 +- ui/src/main/Routes/Routes.tsx | 15 +- ui/src/main/Top/Top.test.tsx | 74 +- ui/src/main/Top/Top.tsx | 25 +- ui/src/main/index.ts | 12 +- ui/src/pages/Login/Login.test.tsx | 140 +- ui/src/pages/Login/Login.tsx | 35 +- ui/src/pages/Login/Login.validations.ts | 6 + ui/src/pages/NotFound/NotFound.test.tsx | 8 +- ui/src/pages/NotFound/NotFound.tsx | 13 +- ui/src/pages/Profile/Profile.test.tsx | 29 +- ui/src/pages/Profile/Profile.tsx | 9 +- .../components/PasswordDetails.test.tsx | 119 +- .../Profile/components/PasswordDetails.tsx | 59 +- .../components/PasswordDetails.validations.ts | 13 + .../components/ProfileDetails.test.tsx | 136 +- .../Profile/components/ProfileDetails.tsx | 34 +- .../components/ProfileDetails.validations.ts | 10 + .../RecoverLostPassword.test.tsx | 64 +- .../RecoverLostPassword.tsx | 19 +- .../RecoverLostPassword.validations.ts | 5 + ui/src/pages/Register/Register.test.tsx | 173 +- ui/src/pages/Register/Register.tsx | 59 +- ui/src/pages/Register/Register.utils.ts | 11 + ui/src/pages/Register/Register.validations.ts | 16 + ui/src/pages/SecretMain/SecretMain.test.tsx | 6 +- ui/src/pages/SecretMain/SecretMain.tsx | 3 +- ui/src/pages/Welcome/Welcome.test.tsx | 14 +- ui/src/pages/Welcome/Welcome.tsx | 38 +- ui/src/pages/index.ts | 14 +- ui/src/react-app-env.d.ts | 1 - ui/src/serviceWorker.ts | 139 - ui/src/setupTest.ts | 6 + ui/src/setupTests.ts | 5 - ui/src/tests/index.ts | 2 +- ui/src/tests/utils.tsx | 12 +- ui/src/vite-env.d.ts | 1 + ui/tsconfig.app.json | 44 + ui/tsconfig.json | 21 +- ui/tsconfig.node.json | 24 + ui/vite.config.ts | 29 + ui/vitest.config.ts | 19 + ui/yarn.lock | 12475 ++++------------ 82 files changed, 4510 insertions(+), 10610 deletions(-) create mode 100644 ui/.gitignore create mode 100644 ui/eslint.config.js create mode 100644 ui/index.html delete mode 100644 ui/public/index.html create mode 100644 ui/src/contexts/UserContext/User.context.ts create mode 100644 ui/src/contexts/UserContext/UserContext.constants.ts delete mode 100644 ui/src/index.tsx create mode 100644 ui/src/main.tsx create mode 100644 ui/src/pages/Login/Login.validations.ts create mode 100644 ui/src/pages/Profile/components/PasswordDetails.validations.ts create mode 100644 ui/src/pages/Profile/components/ProfileDetails.validations.ts create mode 100644 ui/src/pages/RecoverLostPassword/RecoverLostPassword.validations.ts create mode 100644 ui/src/pages/Register/Register.utils.ts create mode 100644 ui/src/pages/Register/Register.validations.ts delete mode 100644 ui/src/react-app-env.d.ts delete mode 100644 ui/src/serviceWorker.ts create mode 100644 ui/src/setupTest.ts delete mode 100644 ui/src/setupTests.ts create mode 100644 ui/src/vite-env.d.ts create mode 100644 ui/tsconfig.app.json create mode 100644 ui/tsconfig.node.json create mode 100644 ui/vite.config.ts create mode 100644 ui/vitest.config.ts diff --git a/ui/.env.example b/ui/.env.example index e4d2f9247..98143b7e8 100644 --- a/ui/.env.example +++ b/ui/.env.example @@ -1 +1 @@ -REACT_APP_BASE_URL = "http://localhost:3000/api/v1" +VITE_APP_BASE_URL = "http://localhost:8080/api/v1" diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/ui/.prettierrc b/ui/.prettierrc index 963354f23..4a0a0628f 100644 --- a/ui/.prettierrc +++ b/ui/.prettierrc @@ -1,3 +1,6 @@ { - "printWidth": 120 + "printWidth": 80, + "trailingComma": "es5", + "tabWidth": 2, + "singleQuote": true } diff --git a/ui/eslint.config.js b/ui/eslint.config.js new file mode 100644 index 000000000..109373953 --- /dev/null +++ b/ui/eslint.config.js @@ -0,0 +1,35 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; +import eslintPluginPrettier from 'eslint-plugin-prettier/recommended'; + +export default tseslint.config( + { ignores: ['dist', 'src/api'] }, + { + extends: [ + js.configs.recommended, + ...tseslint.configs.recommended, + eslintPluginPrettier, + ], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-explicit-any': 'off', + 'prettier/prettier': ['error', { printWidth: 80 }], + }, + } +); diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 000000000..be40eed69 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Bootzooka + + +
+ + + diff --git a/ui/openapi-codegen.config.ts b/ui/openapi-codegen.config.ts index 49892bfce..8901eae84 100644 --- a/ui/openapi-codegen.config.ts +++ b/ui/openapi-codegen.config.ts @@ -1,14 +1,17 @@ -import { generateSchemaTypes, generateReactQueryComponents } from "@openapi-codegen/typescript"; -import { defineConfig } from "@openapi-codegen/cli"; +import { + generateSchemaTypes, + generateReactQueryComponents, +} from '@openapi-codegen/typescript'; +import { defineConfig } from '@openapi-codegen/cli'; export default defineConfig({ apiFile: { from: { - relativePath: "../backend/target/openapi.yaml", - source: "file", + relativePath: '../backend/target/openapi.yaml', + source: 'file', }, - outputDir: "./src/api", + outputDir: './src/api', to: async (context) => { - const filenamePrefix = "api"; + const filenamePrefix = 'api'; const { schemasFiles } = await generateSchemaTypes(context, { filenamePrefix, }); @@ -20,12 +23,12 @@ export default defineConfig({ }, apiWeb: { from: { - source: "url", - url: "http://localhost:8080/api/v1/docs/docs.yaml", + source: 'url', + url: 'http://localhost:8080/api/v1/docs/docs.yaml', }, - outputDir: "./src/api", + outputDir: './src/api', to: async (context) => { - const filenamePrefix = "api"; + const filenamePrefix = 'api'; const { schemasFiles } = await generateSchemaTypes(context, { filenamePrefix, }); diff --git a/ui/package.json b/ui/package.json index d8870d06f..1ebadf47d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,57 +1,65 @@ { "name": "bootzooka-ui", "version": "0.1.0", + "type": "module", "private": true, "proxy": "http://localhost:8080", "engines": { "node": ">=22" }, + "scripts": { + "start": "yarn generate:openapi-types && concurrently vite \"yarn watch:openapi\"", + "build": "yarn generate:openapi-types && tsc -b && vite build", + "preview": "vite preview", + "test": "vitest", + "test:coverage": "vitest run --coverage --watchAll=false", + "test:ci": "CI=true vitest --maxWorkers 2", + "lint": "eslint .", + "generate:openapi-types": "npx openapi-codegen gen apiWeb --source file --relativePath ../backend/target/openapi.yaml", + "watch:openapi": "chokidar '../backend/target/openapi.yaml' -c 'yarn generate:openapi-types'", + "start:frontend": "yarn start" + }, "dependencies": { "@tanstack/react-query": "^5.62.7", - "@testing-library/jest-dom": "^6.2.0", - "@testing-library/react": "^14.1.2", - "@testing-library/user-event": "^14.5.2", - "@types/jest": "^29.2.6", - "@types/node": "^20.10.7", - "@types/react": "^18.2.47", - "@types/react-dom": "^18.2.18", - "@types/react-icons": "^3.0.0", - "@types/react-router-bootstrap": "^0.26.6", - "@types/react-router-dom": "^5.3.3", - "@types/yup": "^0.32.0", "bootstrap": "^5.3.2", - "eslint-config-prettier": "^9.1.0", "formik": "^2.4.5", "immer": "^10.0.3", - "prettier": "^3.1.1", "react": "^18.2.0", "react-bootstrap": "^2.9.2", "react-dom": "^18.2.0", "react-icons": "^4.12.0", + "react-router": "^7.1.3", "react-router-bootstrap": "^0.26.2", - "react-router-dom": "^6.21.1", - "react-scripts": "^5.0.1", - "typescript": "5.7.3", "yup": "^1.3.3" }, - "scripts": { - "start": "yarn generate:openapi-types && concurrently \"react-scripts start\" \"yarn watch:openapi\"", - "build": "yarn generate:openapi-types && react-scripts build", - "test": "react-scripts test", - "test:coverage": "react-scripts test --coverage --watchAll=false", - "test:ci": "CI=true react-scripts test --maxWorkers 2", - "lint": "eslint --ext .ts,.tsx . && prettier --write ./src", - "eject": "react-scripts eject", - "generate:openapi-types": "npx openapi-codegen gen apiWeb --source file --relativePath ../backend/target/openapi.yaml", - "watch:openapi": "chokidar '../backend/target/openapi.yaml' -c 'yarn generate:openapi-types'", - "start:frontend": "yarn start" - }, - "eslintConfig": { - "extends": [ - "react-app", - "react-app/jest", - "prettier" - ] + "devDependencies": { + "@eslint/js": "^9.18.0", + "@openapi-codegen/cli": "^2.0.2", + "@openapi-codegen/typescript": "^8.0.2", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.6.0", + "@types/node": "^22.10.7", + "@types/react": "^19.0.7", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react-swc": "^3.7.2", + "@vitest/coverage-v8": "^3.0.2", + "chokidar-cli": "^3.0.0", + "concurrently": "^8.2.2", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "eslint-plugin-react": "^7.37.4", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.18", + "globals": "^15.14.0", + "jsdom": "^26.0.0", + "prettier": "^3.4.2", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0", + "vite": "^6.0.7", + "vitest": "^3.0.2" }, "jest": { "collectCoverageFrom": [ @@ -71,12 +79,5 @@ "last 1 firefox version", "last 1 safari version" ] - }, - "devDependencies": { - "@babel/plugin-proposal-private-property-in-object": "^7.21.11", - "@openapi-codegen/cli": "^2.0.2", - "@openapi-codegen/typescript": "^8.0.2", - "chokidar-cli": "^3.0.0", - "concurrently": "^8.2.2" } } diff --git a/ui/public/index.html b/ui/public/index.html deleted file mode 100644 index 9df7fb9f6..000000000 --- a/ui/public/index.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - Bootzooka - - - -
- - - diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 579ce6dfd..63d5e6b5c 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -1,8 +1,8 @@ -import { render, screen } from "@testing-library/react"; -import { App } from "./App"; +import { render, screen } from '@testing-library/react'; +import { App } from './App'; -test("should render", () => { +test('should render', () => { render(); - const header = screen.getByText("Welcome to Bootzooka!"); + const header = screen.getByText('Welcome to Bootzooka!'); expect(header).toBeInTheDocument(); }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ddda0edc5..ce340d3ec 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,12 +1,11 @@ -import React from "react"; -import { BrowserRouter } from "react-router-dom"; -import { Main } from "main"; -import { UserContextProvider } from "contexts"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Main } from 'main/Main/Main'; +import { UserContextProvider } from 'contexts'; const queryClient = new QueryClient(); -export const App: React.FC = () => ( +export const App = () => ( diff --git a/ui/src/api/apiContext.ts b/ui/src/api/apiContext.ts index 571eff67c..01c8dec60 100644 --- a/ui/src/api/apiContext.ts +++ b/ui/src/api/apiContext.ts @@ -1,5 +1,5 @@ -import type { QueryKey, UseQueryOptions } from "@tanstack/react-query"; -import { QueryOperation } from "./apiComponents"; +import type { QueryKey, UseQueryOptions } from '@tanstack/react-query'; +import { QueryOperation } from './apiComponents'; export type ApiContext = { fetcherOptions: { @@ -35,7 +35,12 @@ export function useApiContext< TError = unknown, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, ->(_queryOptions?: Omit, "queryKey" | "queryFn">): ApiContext { +>( + _queryOptions?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + > +): ApiContext { return { fetcherOptions: {}, queryOptions: {}, @@ -46,10 +51,10 @@ export function useApiContext< export const queryKeyFn = (operation: QueryOperation) => { const queryKey: unknown[] = hasPathParams(operation) ? operation.path - .split("/") + .split('/') .filter(Boolean) .map((i) => resolvePathParam(i, operation.variables.pathParams)) - : operation.path.split("/").filter(Boolean); + : operation.path.split('/').filter(Boolean); if (hasQueryParams(operation)) { queryKey.push(operation.variables.queryParams); @@ -63,14 +68,14 @@ export const queryKeyFn = (operation: QueryOperation) => { }; // Helpers const resolvePathParam = (key: string, pathParams: Record) => { - if (key.startsWith("{") && key.endsWith("}")) { + if (key.startsWith('{') && key.endsWith('}')) { return pathParams[key.slice(1, -1)]; } return key; }; const hasPathParams = ( - operation: QueryOperation, + operation: QueryOperation ): operation is QueryOperation & { variables: { pathParams: Record }; } => { @@ -78,7 +83,7 @@ const hasPathParams = ( }; const hasBody = ( - operation: QueryOperation, + operation: QueryOperation ): operation is QueryOperation & { variables: { body: Record }; } => { @@ -86,7 +91,7 @@ const hasBody = ( }; const hasQueryParams = ( - operation: QueryOperation, + operation: QueryOperation ): operation is QueryOperation & { variables: { queryParams: Record }; } => { diff --git a/ui/src/api/apiFetcher.ts b/ui/src/api/apiFetcher.ts index c02ada504..7a91f43ee 100644 --- a/ui/src/api/apiFetcher.ts +++ b/ui/src/api/apiFetcher.ts @@ -1,8 +1,10 @@ -import { ApiContext } from "./apiContext"; +import { ApiContext } from './apiContext'; const baseUrl = process.env.REACT_APP_BASE_URL; -export type ErrorWrapper = TError | { status: "unknown"; payload: string }; +export type ErrorWrapper = + | TError + | { status: 'unknown'; payload: string }; export type ApiFetcherOptions = { url: string; @@ -12,7 +14,7 @@ export type ApiFetcherOptions = { queryParams?: TQueryParams; pathParams?: TPathParams; signal?: AbortSignal; -} & ApiContext["fetcherOptions"]; +} & ApiContext['fetcherOptions']; export async function apiFetch< TData, @@ -29,15 +31,20 @@ export async function apiFetch< pathParams, queryParams, signal, -}: ApiFetcherOptions): Promise { +}: ApiFetcherOptions< + TBody, + THeaders, + TQueryParams, + TPathParams +>): Promise { try { const requestHeaders: HeadersInit = { - "Content-Type": "application/json", + 'Content-Type': 'application/json', ...headers, }; - if (!requestHeaders.Authorization && localStorage.getItem("apiKey")) { - requestHeaders.Authorization = `Bearer ${localStorage.getItem("apiKey")}`; + if (!requestHeaders.Authorization && localStorage.getItem('apiKey')) { + requestHeaders.Authorization = `Bearer ${localStorage.getItem('apiKey')}`; } /** @@ -46,47 +53,66 @@ export async function apiFetch< * 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"]; + 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, - }); + 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", + status: 'unknown' as const, + payload: + e instanceof Error + ? `Unexpected error (${e.message})` + : 'Unexpected error', }; } throw error; } - if (response.headers.get("content-type")?.includes("json")) { + 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", + const 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 = {}) => { +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/ui/src/components/ErrorMessage/ErrorMessage.tsx b/ui/src/components/ErrorMessage/ErrorMessage.tsx index cc036f5b1..1dd9cd751 100644 --- a/ui/src/components/ErrorMessage/ErrorMessage.tsx +++ b/ui/src/components/ErrorMessage/ErrorMessage.tsx @@ -1,9 +1,13 @@ -import React from "react"; - interface ErrorMessageProps { error: any; } export const ErrorMessage: React.FC = ({ error }) => ( - {(error?.response?.data?.error || error?.message || "Unknown error").toString()} + + {( + error?.response?.data?.error || + error?.message || + 'Unknown error' + ).toString()} + ); diff --git a/ui/src/components/FeedbackButton/FeedbackButton.tsx b/ui/src/components/FeedbackButton/FeedbackButton.tsx index 036112a45..e050a1719 100644 --- a/ui/src/components/FeedbackButton/FeedbackButton.tsx +++ b/ui/src/components/FeedbackButton/FeedbackButton.tsx @@ -1,12 +1,12 @@ -import { ReactElement } from "react"; -import Button, { ButtonProps } from "react-bootstrap/Button"; -import Spinner from "react-bootstrap/Spinner"; -import Form from "react-bootstrap/Form"; -import { IconType } from "react-icons"; -import { BsExclamationCircle, BsCheck } from "react-icons/bs"; -import useFormikValuesChanged from "./useFormikValuesChanged"; -import { ErrorMessage } from "../"; -import { UseMutationResult } from "@tanstack/react-query"; +import { ReactElement } from 'react'; +import Button, { ButtonProps } from 'react-bootstrap/Button'; +import Spinner from 'react-bootstrap/Spinner'; +import Form from 'react-bootstrap/Form'; +import { IconType } from 'react-icons'; +import { BsExclamationCircle, BsCheck } from 'react-icons/bs'; +import useFormikValuesChanged from './useFormikValuesChanged'; +import { ErrorMessage } from '../'; +import { UseMutationResult } from '@tanstack/react-query'; interface FeedbackButtonProps extends ButtonProps { label: string; @@ -19,14 +19,14 @@ export const FeedbackButton = ({ mutation, label, Icon, - successLabel = "Success", + successLabel = 'Success', ...buttonProps }: FeedbackButtonProps): ReactElement => { useFormikValuesChanged(() => { - !mutation.isIdle && mutation.reset(); + return !mutation.isIdle && mutation.reset(); }); - if (mutation.isPending) { + if (mutation?.isPending) { return ( - {successLabel} + + {successLabel} + ); } diff --git a/ui/src/components/FeedbackButton/useFormikValuesChanged.tsx b/ui/src/components/FeedbackButton/useFormikValuesChanged.tsx index b7e10e9b2..14dcd6f0b 100644 --- a/ui/src/components/FeedbackButton/useFormikValuesChanged.tsx +++ b/ui/src/components/FeedbackButton/useFormikValuesChanged.tsx @@ -1,15 +1,15 @@ -import React from "react"; -import { useFormikContext } from "formik"; +import { useEffect, useRef } from 'react'; +import { useFormikContext } from 'formik'; const useFormikValuesChanged = (onChange: () => void) => { const { values } = useFormikContext(); - const onChangeRef = React.useRef(onChange); + const onChangeRef = useRef(onChange); - React.useEffect(() => { + useEffect(() => { onChangeRef.current = onChange; }, [onChange]); - React.useEffect(() => { + useEffect(() => { onChangeRef.current(); }, [values]); }; diff --git a/ui/src/components/FormikInput/FormikInput.tsx b/ui/src/components/FormikInput/FormikInput.tsx index 3ef2f796d..7a7f017fb 100644 --- a/ui/src/components/FormikInput/FormikInput.tsx +++ b/ui/src/components/FormikInput/FormikInput.tsx @@ -1,8 +1,7 @@ -import React from "react"; -import { Field, FieldProps } from "formik"; -import Form from "react-bootstrap/Form"; -import Row from "react-bootstrap/Row"; -import Col from "react-bootstrap/Col"; +import { Field, FieldProps } from 'formik'; +import Form from 'react-bootstrap/Form'; +import Row from 'react-bootstrap/Row'; +import Col from 'react-bootstrap/Col'; interface FormikInputProps { type?: string; @@ -10,7 +9,11 @@ interface FormikInputProps { label: string; } -export const FormikInput: React.FC = ({ type = "text", name, label }) => ( +export const FormikInput: React.FC = ({ + type = 'text', + name, + label, +}) => ( {({ field, meta }: FieldProps) => ( diff --git a/ui/src/components/TwoColumnHero/TwoColumnHero.tsx b/ui/src/components/TwoColumnHero/TwoColumnHero.tsx index 0ae7bdd14..0b6e61afb 100644 --- a/ui/src/components/TwoColumnHero/TwoColumnHero.tsx +++ b/ui/src/components/TwoColumnHero/TwoColumnHero.tsx @@ -1,11 +1,9 @@ -import React from "react"; - -import Container from "react-bootstrap/Container"; -import Col from "react-bootstrap/Col"; -import Row from "react-bootstrap/Row"; -import Fade from "react-bootstrap/Fade"; -import Image from "react-bootstrap/Image"; -import logo from "assets/sml-logo-vertical-white-all-trans.png"; +import Container from 'react-bootstrap/Container'; +import Col from 'react-bootstrap/Col'; +import Row from 'react-bootstrap/Row'; +import Fade from 'react-bootstrap/Fade'; +import Image from 'react-bootstrap/Image'; +import logo from 'assets/sml-logo-vertical-white-all-trans.png'; interface TwoColumnHeroProps { children: React.ReactNode; @@ -15,7 +13,11 @@ export const TwoColumnHero: React.FC = ({ children }) => { return ( - + SoftwareMill logotype diff --git a/ui/src/components/index.ts b/ui/src/components/index.ts index 7103823fa..dd97bb1fd 100644 --- a/ui/src/components/index.ts +++ b/ui/src/components/index.ts @@ -1,4 +1,4 @@ -export * from "./ErrorMessage/ErrorMessage"; -export * from "./FeedbackButton/FeedbackButton"; -export * from "./FormikInput/FormikInput"; -export * from "./TwoColumnHero/TwoColumnHero"; +export * from './ErrorMessage/ErrorMessage'; +export * from './FeedbackButton/FeedbackButton'; +export * from './FormikInput/FormikInput'; +export * from './TwoColumnHero/TwoColumnHero'; diff --git a/ui/src/contexts/UserContext/User.context.ts b/ui/src/contexts/UserContext/User.context.ts new file mode 100644 index 000000000..474b3eeed --- /dev/null +++ b/ui/src/contexts/UserContext/User.context.ts @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; +import { UserAction, UserState } from './UserContext'; +import { initialUserState } from './UserContext.constants'; + +export const UserContext = createContext<{ + state: UserState; + dispatch: React.Dispatch; +}>({ + state: initialUserState, + dispatch: () => {}, +}); + +export const useUserContext = () => useContext(UserContext); diff --git a/ui/src/contexts/UserContext/UserContext.constants.ts b/ui/src/contexts/UserContext/UserContext.constants.ts new file mode 100644 index 000000000..4f496f1f5 --- /dev/null +++ b/ui/src/contexts/UserContext/UserContext.constants.ts @@ -0,0 +1,7 @@ +import { UserState } from './UserContext'; + +export const initialUserState: UserState = { + apiKey: null, + user: null, + loggedIn: null, +}; diff --git a/ui/src/contexts/UserContext/UserContext.test.tsx b/ui/src/contexts/UserContext/UserContext.test.tsx index 49f41a810..1b2156989 100644 --- a/ui/src/contexts/UserContext/UserContext.test.tsx +++ b/ui/src/contexts/UserContext/UserContext.test.tsx @@ -1,10 +1,14 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; -import { UserContextProvider, UserContext, UserAction } from "./UserContext"; -import { userEvent } from "@testing-library/user-event"; - -const TestComponent: React.FC<{ action?: UserAction; label?: string }> = ({ action, label }) => { - const { state, dispatch } = React.useContext(UserContext); +import { useContext } from 'react'; +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { UserContextProvider, UserAction } from './UserContext'; +import { UserContext } from './User.context'; + +const TestComponent: React.FC<{ action?: UserAction; label?: string }> = ({ + action, + label, +}) => { + const { state, dispatch } = useContext(UserContext); return ( <>
{JSON.stringify(state)}
@@ -13,121 +17,155 @@ const TestComponent: React.FC<{ action?: UserAction; label?: string }> = ({ acti ); }; -test("handles set api key action", async () => { +test('handles set api key action', async () => { render( - - , + +
); - expect(screen.getByText('{"apiKey":null,"user":null,"loggedIn":null}')).toBeInTheDocument(); + expect( + screen.getByText('{"apiKey":null,"user":null,"loggedIn":null}') + ).toBeInTheDocument(); - await userEvent.click(screen.getByText("set api key")); + await userEvent.click(screen.getByText('set api key')); - expect(screen.getByText('{"apiKey":"test-api-key","user":null,"loggedIn":null}')).toBeInTheDocument(); + expect( + screen.getByText('{"apiKey":"test-api-key","user":null,"loggedIn":null}') + ).toBeInTheDocument(); }); -test("handles login action", async () => { +test('handles login action', async () => { render( - , + ); - expect(screen.getByText('{"apiKey":null,"user":null,"loggedIn":null}')).toBeInTheDocument(); + expect( + screen.getByText('{"apiKey":null,"user":null,"loggedIn":null}') + ).toBeInTheDocument(); - await userEvent.click(screen.getByText("log in")); + await userEvent.click(screen.getByText('log in')); expect( screen.getByText( - '{"apiKey":null,"user":{"login":"user-login","email":"email@address.pl","createdOn":"2020-10-09T09:57:17.995288Z"},"loggedIn":true}', - ), + '{"apiKey":null,"user":{"login":"user-login","email":"email@address.pl","createdOn":"2020-10-09T09:57:17.995288Z"},"loggedIn":true}' + ) ).toBeInTheDocument(); }); -test("handles log in and update details action", async () => { +test('handles log in and update details action', async () => { render( - , + ); - expect(screen.getAllByText('{"apiKey":null,"user":null,"loggedIn":null}')[0]).toBeInTheDocument(); + expect( + screen.getAllByText('{"apiKey":null,"user":null,"loggedIn":null}')[0] + ).toBeInTheDocument(); - await userEvent.click(screen.getByText("update user data")); + await userEvent.click(screen.getByText('update user data')); - expect(screen.getAllByText('{"apiKey":null,"user":null,"loggedIn":null}')[0]).toBeInTheDocument(); + expect( + screen.getAllByText('{"apiKey":null,"user":null,"loggedIn":null}')[0] + ).toBeInTheDocument(); - await userEvent.click(screen.getByText("log in")); + await userEvent.click(screen.getByText('log in')); expect( screen.getAllByText( - '{"apiKey":null,"user":{"login":"user-login","email":"email@address.pl","createdOn":"2020-10-09T09:57:17.995288Z"},"loggedIn":true}', - )[0], + '{"apiKey":null,"user":{"login":"user-login","email":"email@address.pl","createdOn":"2020-10-09T09:57:17.995288Z"},"loggedIn":true}' + )[0] ).toBeInTheDocument(); - await userEvent.click(screen.getByText("update user data")); + await userEvent.click(screen.getByText('update user data')); expect( screen.getAllByText( - '{"apiKey":null,"user":{"login":"updated-user-login","email":"updatedEmail@address.pl","createdOn":"2020-10-09T09:57:17.995288Z"},"loggedIn":true}', - )[0], + '{"apiKey":null,"user":{"login":"updated-user-login","email":"updatedEmail@address.pl","createdOn":"2020-10-09T09:57:17.995288Z"},"loggedIn":true}' + )[0] ).toBeInTheDocument(); }); -test("handles log in and log out action", async () => { +test('handles log in and log out action', async () => { render( - , + ); - expect(screen.getAllByText('{"apiKey":null,"user":null,"loggedIn":null}')[0]).toBeInTheDocument(); + expect( + screen.getAllByText('{"apiKey":null,"user":null,"loggedIn":null}')[0] + ).toBeInTheDocument(); - await userEvent.click(screen.getByText("log out")); + await userEvent.click(screen.getByText('log out')); - expect(screen.getAllByText('{"apiKey":null,"user":null,"loggedIn":false}')[0]).toBeInTheDocument(); + expect( + screen.getAllByText('{"apiKey":null,"user":null,"loggedIn":false}')[0] + ).toBeInTheDocument(); - await userEvent.click(screen.getByText("log in")); + await userEvent.click(screen.getByText('log in')); expect( screen.getAllByText( - '{"apiKey":null,"user":{"login":"user-login","email":"email@address.pl","createdOn":"2020-10-09T09:57:17.995288Z"},"loggedIn":true}', - )[0], + '{"apiKey":null,"user":{"login":"user-login","email":"email@address.pl","createdOn":"2020-10-09T09:57:17.995288Z"},"loggedIn":true}' + )[0] ).toBeInTheDocument(); - await userEvent.click(screen.getByText("log out")); + await userEvent.click(screen.getByText('log out')); - expect(screen.getAllByText('{"apiKey":null,"user":null,"loggedIn":false}')[0]).toBeInTheDocument(); + expect( + screen.getAllByText('{"apiKey":null,"user":null,"loggedIn":false}')[0] + ).toBeInTheDocument(); }); diff --git a/ui/src/contexts/UserContext/UserContext.tsx b/ui/src/contexts/UserContext/UserContext.tsx index 63bc65978..e3ac9ac0b 100644 --- a/ui/src/contexts/UserContext/UserContext.tsx +++ b/ui/src/contexts/UserContext/UserContext.tsx @@ -1,5 +1,7 @@ -import React, { ReactNode } from "react"; -import { produce } from "immer"; +import { ReactNode, useReducer } from 'react'; +import { produce } from 'immer'; +import { initialUserState } from './UserContext.constants'; +import { UserContext } from './User.context'; export interface UserDetails { createdOn: string; @@ -13,38 +15,32 @@ export interface UserState { loggedIn: boolean | null; } -export const initialUserState: UserState = { - apiKey: null, - user: null, - loggedIn: null, -}; - export type UserAction = - | { type: "SET_API_KEY"; apiKey: string | null } - | { type: "UPDATE_USER_DATA"; user: Partial } - | { type: "LOG_IN"; user: UserDetails } - | { type: "LOG_OUT" }; + | { type: 'SET_API_KEY'; apiKey: string | null } + | { type: 'UPDATE_USER_DATA'; user: Partial } + | { type: 'LOG_IN'; user: UserDetails } + | { type: 'LOG_OUT' }; const userReducer = (state: UserState, action: UserAction): UserState => { switch (action.type) { - case "SET_API_KEY": + case 'SET_API_KEY': return produce(state, (draftState) => { draftState.apiKey = action.apiKey; }); - case "UPDATE_USER_DATA": + case 'UPDATE_USER_DATA': return produce(state, (draftState) => { if (!draftState.user) return; draftState.user = { ...draftState.user, ...action.user }; }); - case "LOG_IN": + case 'LOG_IN': return produce(state, (draftState) => { draftState.user = action.user; draftState.loggedIn = true; }); - case "LOG_OUT": + case 'LOG_OUT': return produce(state, (draftState) => { draftState.apiKey = null; draftState.user = null; @@ -56,22 +52,18 @@ const userReducer = (state: UserState, action: UserAction): UserState => { } }; -export const UserContext = React.createContext<{ - state: UserState; - dispatch: React.Dispatch; -}>({ - state: initialUserState, - dispatch: () => {}, -}); - interface UserContextProviderProps { children: ReactNode; } -export const UserContextProvider: React.FC = ({ children }) => { - const [state, dispatch] = React.useReducer(userReducer, initialUserState); +export const UserContextProvider: React.FC = ({ + children, +}) => { + const [state, dispatch] = useReducer(userReducer, initialUserState); - return {children}; + return ( + + {children} + + ); }; - -export const useUserContext = () => React.useContext(UserContext); diff --git a/ui/src/contexts/index.ts b/ui/src/contexts/index.ts index f6809154a..98ee35922 100644 --- a/ui/src/contexts/index.ts +++ b/ui/src/contexts/index.ts @@ -1 +1 @@ -export * from "./UserContext/UserContext"; +export * from './UserContext/UserContext'; diff --git a/ui/src/index.tsx b/ui/src/index.tsx deleted file mode 100644 index c5f284f4f..000000000 --- a/ui/src/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { createRoot } from "react-dom/client"; -import { App } from "./App"; -import * as serviceWorker from "./serviceWorker"; -import "bootstrap/dist/css/bootstrap.min.css"; -import "./index.css"; - -const root = createRoot(document.getElementById("root")!); -root.render(); - -// If you want your app to work offline and load faster, you can change -// unregister() to register() below. Note this comes with some pitfalls. -// Learn more about service workers: https://bit.ly/CRA-PWA -serviceWorker.unregister(); diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 000000000..8103390e3 --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; +import 'bootstrap/dist/css/bootstrap.min.css'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/ui/src/main/Footer/Footer.test.tsx b/ui/src/main/Footer/Footer.test.tsx index e5922fe21..ce4c4d660 100644 --- a/ui/src/main/Footer/Footer.test.tsx +++ b/ui/src/main/Footer/Footer.test.tsx @@ -1,25 +1,26 @@ -import { screen } from "@testing-library/react"; -import { Footer } from "./Footer"; -import { renderWithClient } from "tests"; -import { useGetAdminVersion } from "api/apiComponents"; +import { Mock } from 'vitest'; +import { screen } from '@testing-library/react'; +import { renderWithClient } from 'tests'; +import { useGetAdminVersion } from 'api/apiComponents'; +import { Footer } from './Footer'; -jest.mock("api/apiComponents", () => ({ - useGetAdminVersion: jest.fn(), +vi.mock('api/apiComponents', () => ({ + useGetAdminVersion: vi.fn(), })); beforeEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); -test("renders version data", async () => { - const mockedUseGetAdminVersion = useGetAdminVersion as jest.Mock; +test('renders version data', async () => { + const mockedUseGetAdminVersion = useGetAdminVersion as Mock; mockedUseGetAdminVersion.mockReturnValue({ isPending: false, isLoading: false, isError: false, isSuccess: true, - data: { buildDate: "testDate", buildSha: "testSha" }, + data: { buildDate: 'testDate', buildSha: 'testSha' }, }); renderWithClient(