diff --git a/README.md b/README.md index 5fc311e18..7dd10b19a 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,12 @@ It is written using React 17, Typescript 4, and Node 16. ### To create a personal config in order to track logs to your local environment -- Add [hostname] to src/config/app-host-environment.enum.ts -- Copy an existing config from src/config/environment.*.config.ts +- Add [hostname] to src-ts/config/app-host-environment.enum.ts +- Copy an existing config from src-ts/config/environment.*.config.ts - Rename new config environment.[hostname].config.ts - Rename config variable to EnvironmentConfig[HostName] - Set the ENV variable to AppHostEnvironment.[hostnama] -- Add the switch case for the host name to src/config/environment.config.ts +- Add the switch case for the host name to src-ts/config/environment.config.ts - Prior to starting the server, set your host name: \> export REACT_APP_HOST_ENV=[hostname] @@ -54,7 +54,7 @@ It is written using React 17, Typescript 4, and Node 16. ## Developing The following descriptions correspond to the top-level directories within the -src directory. +src-ts directory. ### config @@ -98,9 +98,9 @@ The Tool Selectors correlate 1:1 to directories within the tools directory. The name of a tool's directory should correlate w/the name of the tool and its url. -I.e. src/tools/[tool-name] == platform.topcoder.com/[tool-name] +I.e. src-ts/tools/[tool-name] == platform.topcoder.com/[tool-name] -E.g. src/tools/self-service == platform.topcoder.com/self-service +E.g. src-ts/tools/self-service == platform.topcoder.com/self-service Tools should generally not import modules from any directories other than lib. @@ -112,11 +112,11 @@ generally be moved to lib. All of the routes for a tool, including root, section, and subsections should be defined in a top-level file. -I.e. [toolName]Routes in src/tools/[tool-name]/[tool-name].routes.ts +I.e. [toolName]Routes in src-ts/tools/[tool-name]/[tool-name].routes.ts -E.g. selfServiceRoutes in src/tools/self-service/self-service.routes.ts +E.g. selfServiceRoutes in src-ts/tools/self-service/self-service.routes.ts -These routes then need to be added to the src/tools/tools.routes.ts file, +These routes then need to be added to the src-ts/tools/tools.routes.ts file, at which time the tool selectors should automatically be updatd. ### utils @@ -127,9 +127,9 @@ The Utility Selectors correlate 1:1 to directories within the utils directory. The name of a util's directory should correlate w/the name of the util and its url. -I.e. src/utils/[util-name] == platform.topcoder.com/[util-name] +I.e. src-ts/utils/[util-name] == platform.topcoder.com/[util-name] -E.g. src/utils/profile == platform.topcoder.com/profile +E.g. src-ts/utils/profile == platform.topcoder.com/profile Utils should generally not import modules from any directories other than lib. @@ -141,9 +141,9 @@ generally be moved to lib. All of the routes for a util, including root, section, and subsections should be defined in a top-level file. -I.e. [utilName]Routes in src/utils/[util-name]/[util-name].routes.ts +I.e. [utilName]Routes in src-ts/utils/[util-name]/[util-name].routes.ts -E.g. homeRoutes in src/tools/home/home.routes.ts +E.g. homeRoutes in src-ts/tools/home/home.routes.ts -These routes then need to be added to the src/utils/utils.routes.ts file, +These routes then need to be added to the src-ts/utils/utils.routes.ts file, at which time the tool selectors should automatically be updated. \ No newline at end of file diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 000000000..cd5b145c6 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,70 @@ +module.exports = function (api) { + const isProd = process.env.APPMODE === "production"; + api.cache(!isProd); + + const generateScopedName = isProd + ? "[hash:base64:6]" + : "self_service_[path][name]___[local]___[hash:base64:6]"; + return { + presets: ["@babel/preset-env", "@babel/preset-react"], + plugins: [ + [ + "@babel/plugin-transform-runtime", + { + useESModules: true, + regenerator: false, + }, + ], + [ + "react-css-modules", + { + filetypes: { + ".scss": { + syntax: "postcss-scss", + }, + }, + generateScopedName, + }, + ], + [ + "inline-react-svg", + { + "svgo": { + "plugins": [ + { + "cleanupIDs": false + } + ] + } + } + ], + ], + env: { + test: { + presets: [ + [ + "@babel/preset-env", + { + targets: "current node", + }, + ], + ], + plugins: [ + [ + "module-resolver", + { + alias: { + styles: "./src/styles", + components: "./src/components", + hooks: "./src/hooks", + utils: "./src/utils", + constants: "./src/constants", + services: "./src/services", + }, + }, + ], + ], + }, + }, + }; +}; diff --git a/config-overrides.js b/config-overrides.js new file mode 100644 index 000000000..75f62850f --- /dev/null +++ b/config-overrides.js @@ -0,0 +1,3 @@ +const { removeModuleScopePlugin } = require('customize-cra') + +module.exports = removeModuleScopePlugin() diff --git a/config/dev.js b/config/dev.js new file mode 100644 index 000000000..99a848249 --- /dev/null +++ b/config/dev.js @@ -0,0 +1,38 @@ +module.exports = { + /** + * URL of Topcoder Community Website + */ + TOPCODER_COMMUNITY_WEBSITE_URL: "https://topcoder-dev.com", + TERMS_URL: + "https://www.topcoder-dev.com/challenges/terms/detail/317cd8f9-d66c-4f2a-8774-63c612d99cd4", + PRIVACY_POLICY_URL: "https://www.topcoder-dev.com/policy", + SIGN_IN_URL: `https://accounts-auth0.topcoder-dev.com/?retUrl=https%3A%2F%2Fplatform.topcoder-dev.com%2Fself-service%2Fwizard®Source=selfService`, + SIGN_UP_URL: `https://accounts-auth0.topcoder-dev.com/?retUrl=https%3A%2F%2Fplatform.topcoder-dev.com%2Fself-service%2Fwizard®Source=selfService&mode=signUp`, + /** + * URL of Topcoder Connect Website + */ + CONNECT_WEBSITE_URL: "https://connect.topcoder-dev.com", + VANILLA_EMBED_JS: "https://vanilla.topcoder-dev.com/js/embed.js", + VANILLA_EMBED_TYPE: "mfe", + VANILLA_FORUM_API: "https://vanilla.topcoder-dev.com/api/v2", + VANILLA_ACCESS_TOKEN: "va.JApNvUOx3549h20I6tnl1kOQDc75NDIp.0jG3dA.EE3gZgV", + + API: { + V5: "https://api.topcoder-dev.com/v5", + V3: "https://api.topcoder-dev.com/v3", + }, + + STRIPE: { + API_KEY: "pk_test_rfcS49MHRVUKomQ9JgSH7Xqz", + API_VERSION: "2020-08-27", + CUSTOMER_TOKEN: + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzMiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.jl6Lp_friVNwEP8nfsfmL-vrQFzOFp2IfM_HC7AwGcg", + ADMIN_TOKEN: + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + }, + /** + * Expire time period of auto saved intake form: 24 hours + */ + AUTO_SAVED_COOKIE_EXPIRED_IN: 24 * 60, + TIME_ZONE: "Europe/London", +}; diff --git a/config/index.js b/config/index.js new file mode 100644 index 000000000..4cbf464b5 --- /dev/null +++ b/config/index.js @@ -0,0 +1,14 @@ +/* global process */ + +module.exports = (() => { + const env = process.env.APPENV || "dev"; + + console.info(`APPENV: "${env}"`); + + // for security reason don't let to require any arbitrary file defined in process.env + if (["prod", "dev"].indexOf(env) < 0) { + return require("./dev"); + } + + return require("./" + env); +})(); diff --git a/config/local.js b/config/local.js new file mode 100644 index 000000000..7af0bba50 --- /dev/null +++ b/config/local.js @@ -0,0 +1,3 @@ +module.exports = { + COMMUNITY_ADMIN_URL: "", +}; diff --git a/config/prod.js b/config/prod.js new file mode 100644 index 000000000..ff5d082bc --- /dev/null +++ b/config/prod.js @@ -0,0 +1,39 @@ +module.exports = { + /** + * URL of Topcoder Community Website + */ + TOPCODER_COMMUNITY_WEBSITE_URL: "https://topcoder.com", + TERMS_URL: + "https://www.topcoder.com/challenges/terms/detail/564a981e-6840-4a5c-894e-d5ad22e9cd6f", + PRIVACY_POLICY_URL: "https://www.topcoder.com/policy", + SIGN_IN_URL: `https://accounts-auth0.topcoder.com/?retUrl=https%3A%2F%2Fplatform.topcoder.com%2Fself-service%2Fwizard®Source=selfService`, + SIGN_UP_URL: `https://accounts-auth0.topcoder.com/?retUrl=https%3A%2F%2Fplatform.topcoder.com%2Fself-service%2Fwizard®Source=selfService&mode=signUp`, + + /** + * URL of Topcoder Connect Website + */ + CONNECT_WEBSITE_URL: "https://connect.topcoder.com", + VANILLA_EMBED_JS: "https://discussions.topcoder.com/js/embed.js", + VANILLA_EMBED_TYPE: "standard", + VANILLA_FORUM_API: "https://vanilla.topcoder.com/api/v2", + VANILLA_ACCESS_TOKEN: "va.JApNvUOx3549h20I6tnl1kOQDc75NDIp.0jG3dA.EE3gZgV", + + API: { + V5: "https://api.topcoder.com/v5", + V3: "https://api.topcoder.com/v3", + }, + + STRIPE: { + API_KEY: "pk_live_m3bCBVSfkfMOEp3unZFRsHXi", + API_VERSION: "2020-08-27", + CUSTOMER_TOKEN: + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzMiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.jl6Lp_friVNwEP8nfsfmL-vrQFzOFp2IfM_HC7AwGcg", + ADMIN_TOKEN: + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw", + }, + /** + * Expire time period of auto saved intake form: 24 hours + */ + AUTO_SAVED_COOKIE_EXPIRED_IN: 24 * 60, + TIME_ZONE: "Europe/London", +}; diff --git a/package.json b/package.json index 9eff64e90..eb3e83f7b 100644 --- a/package.json +++ b/package.json @@ -5,44 +5,115 @@ "scripts": { "start": "sh start-ssl.sh", "start:bsouza": "sh start-ssl-bsouza.sh", + "start:mfe": "cross-env webpack-dev-server --port 8519 --host 0.0.0.0", "build": "react-scripts build", "build:dev": "sh build-dev.sh", "build:prod": "sh build-prod.sh", "eject": "react-scripts eject", - "lint": "tslint src/**/*.{ts,tsx}", - "lint:fix": "tslint src/**/*.{ts,tsx} --fix", + "lint": "tslint 'src-ts/**/*.{ts,tsx}'", + "lint:fix": "tslint 'src-ts/**/*.{ts,tsx}' --fix", + "eslint": "eslint 'src/**/*.{js,jsx}'", + "eslint:fix": "eslint 'src/**/*.{js,jsx}' --fix", "test": "react-scripts test", "test:no-watch": "npm test -- --watchAll=false" }, "dependencies": { "@datadog/browser-logs": "^4.5.0", "@heroicons/react": "^1.0.6", + "apexcharts": "^3.35.3", "axios": "^0.26.1", "browser-cookies": "^1.2.0", "classnames": "^2.3.1", + "crypto-js": "^4.1.1", + "lodash": "^4.17.21", + "moment": "^2.29.3", + "moment-timezone": "^0.5.34", + "prop-types": "^15.8.1", + "rc-checkbox": "^2.3.2", "react": "^17.0.2", + "react-apexcharts": "^1.4.0", "react-dom": "^17.0.2", + "react-elastic-carousel": "^0.11.5", "react-gtm-module": "^2.0.11", + "react-redux": "^8.0.2", + "react-redux-toastr": "^7.6.8", "react-responsive-modal": "^6.2.0", "react-router-dom": "^6.2.1", "react-scripts": "5.0.0", + "react-select": "^5.3.2", + "react-spinners": "^0.13.1", "react-toastify": "^8.2.0", + "react-tooltip": "^4.2.21", + "redux": "^4.2.0", + "redux-logger": "^3.0.6", + "redux-promise-middleware": "^6.1.2", + "redux-thunk": "^2.4.1", "sass": "^1.49.8", + "styled-components": "^5.3.5", "tc-auth-lib": "topcoder-platform/tc-auth-lib#1.0.3", "typescript": "^4.4.2", + "uuid": "^8.3.2", "web-vitals": "^2.1.0" }, "devDependencies": { + "@babel/core": "^7.7.5", + "@babel/plugin-syntax-jsx": "^7.17.12", + "@babel/plugin-transform-runtime": "^7.8.3", + "@babel/preset-env": "^7.7.6", + "@babel/preset-react": "^7.7.4", + "@babel/preset-typescript": "^7.16.7", + "@babel/runtime": "^7.8.7", + "@stripe/react-stripe-js": "1.7.2", + "@stripe/stripe-js": "1.29.0", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^12.0.0", "@testing-library/user-event": "^13.2.1", "@types/axios": "^0.14.0", "@types/jest": "^27.0.1", + "@types/lodash": "^4.14.182", "@types/node": "^16.7.13", + "@types/reach__router": "^1.3.10", "@types/react": "^17.0.20", "@types/react-dom": "^17.0.9", "@types/react-gtm-module": "^2.0.1", - "@types/react-router-dom": "^5.3.3" + "@types/react-redux-toastr": "^7.6.2", + "@types/react-router-dom": "^5.3.3", + "@types/systemjs": "^6.1.0", + "autoprefixer": "^9.8.6", + "babel-eslint": "^11.0.0-beta.2", + "babel-jest": "^24.9.0", + "babel-plugin-inline-react-svg": "^1.1.2", + "babel-plugin-module-resolver": "^4.0.0", + "babel-plugin-react-css-modules": "^5.2.6", + "concurrently": "^5.0.1", + "config": "^3.3.6", + "cross-env": "^7.0.2", + "customize-cra": "^1.0.0", + "eslint": "^8.18.0", + "eslint-config-prettier": "^6.7.0", + "eslint-config-react-app": "^7.0.1", + "eslint-config-react-important-stuff": "^2.0.0", + "eslint-plugin-prettier": "^3.1.1", + "file-loader": "^6.2.0", + "identity-obj-proxy": "^3.0.0", + "jest": "^25.2.7", + "jest-cli": "^25.2.7", + "postcss-loader": "^4.0.4", + "postcss-scss": "^3.0.2", + "prettier": "^2.0.4", + "pretty-quick": "^2.0.1", + "react-app-rewired": "^2.2.1", + "resolve-url-loader": "^3.1.2", + "sass": "^1.48.0", + "sass-loader": "^10.0.5", + "single-spa-react": "^2.14.0", + "style-loader": "^2.0.0", + "systemjs-webpack-interop": "^2.1.2", + "webpack": "^4.41.2", + "webpack-cli": "^3.3.10", + "webpack-config-single-spa-react": "^1.0.3", + "webpack-dev-server": "^3.9.0", + "webpack-merge": "^4.2.2" }, "eslintConfig": { "extends": [ diff --git a/public/index.html b/public/index.html index 3376e76cd..1468259f8 100644 --- a/public/index.html +++ b/public/index.html @@ -8,6 +8,7 @@ + diff --git a/src/App.tsx b/src-ts/App.tsx similarity index 56% rename from src/App.tsx rename to src-ts/App.tsx index 3d5523c04..830c8d433 100644 --- a/src/App.tsx +++ b/src-ts/App.tsx @@ -1,32 +1,27 @@ import { FC, ReactElement, useContext } from 'react' -import { Route, Routes } from 'react-router-dom' +import { Routes } from 'react-router-dom' import { toast, ToastContainer } from 'react-toastify' import { EnvironmentConfig } from './config' import { Header } from './header' -import { analyticsInitialize, logInitialize, ProfileProvider } from './lib' -import { routeContext, RouteContextData } from './lib/route-provider' +import { analyticsInitialize, logInitialize, routeContext, RouteContextData } from './lib' analyticsInitialize(EnvironmentConfig) logInitialize(EnvironmentConfig) const App: FC<{}> = () => { - const { allRoutes }: RouteContextData = useContext(routeContext) + const { allRoutes, getRouteElement }: RouteContextData = useContext(routeContext) const routeElements: Array = allRoutes - .map(route => { - // if the route has children, add the wildcard to the path - const path: string = `${route.route}${!route.children ? '' : '/*'}` - return () - }) + .map(route => getRouteElement(route)) return ( - + <>
{routeElements} - + = () => { draggable pauseOnHover /> - + ) } diff --git a/src-ts/config/constants.ts b/src-ts/config/constants.ts new file mode 100644 index 000000000..f52749e6c --- /dev/null +++ b/src-ts/config/constants.ts @@ -0,0 +1,4 @@ +export enum ToolTitle { + settings = 'Account Settings', + work = 'Work', +} diff --git a/src/config/environments/app-host-environment.enum.ts b/src-ts/config/environments/app-host-environment.enum.ts similarity index 100% rename from src/config/environments/app-host-environment.enum.ts rename to src-ts/config/environments/app-host-environment.enum.ts diff --git a/src/config/environments/environment.bsouza.config.ts b/src-ts/config/environments/environment.bsouza.config.ts similarity index 100% rename from src/config/environments/environment.bsouza.config.ts rename to src-ts/config/environments/environment.bsouza.config.ts diff --git a/src/config/environments/environment.config.ts b/src-ts/config/environments/environment.config.ts similarity index 84% rename from src/config/environments/environment.config.ts rename to src-ts/config/environments/environment.config.ts index 18cb1d720..ccecdb4c4 100644 --- a/src/config/environments/environment.config.ts +++ b/src-ts/config/environments/environment.config.ts @@ -8,7 +8,7 @@ import { EnvironmentConfigProd } from './environment.prod.config' function getEnvironmentConfig(): GlobalConfig { - switch (process.env.REACT_APP_HOST_ENV) { + switch (process.env.APPENV) { case AppHostEnvironment.bsouza: return EnvironmentConfigBsouza @@ -23,7 +23,7 @@ function getEnvironmentConfig(): GlobalConfig { return EnvironmentConfigProd default: - throw new Error(`Cannot initialize an invalid host environment: ${process.env.REACT_APP_HOST_ENV}`) + return EnvironmentConfigDefault } } diff --git a/src/config/environments/environment.default.config.ts b/src-ts/config/environments/environment.default.config.ts similarity index 80% rename from src/config/environments/environment.default.config.ts rename to src-ts/config/environments/environment.default.config.ts index 6b9b02526..b7bf62fa7 100644 --- a/src/config/environments/environment.default.config.ts +++ b/src-ts/config/environments/environment.default.config.ts @@ -4,6 +4,8 @@ import { AppHostEnvironment } from './app-host-environment.enum' export const EnvironmentConfigDefault: GlobalConfig = { API: { + FORUM_ACCESS_TOKEN: 'va.JApNvUOx3549h20I6tnl1kOQDc75NDIp.0jG3dA.EE3gZgV', + FORUM_V2: 'https://vanilla.topcoder-dev.com/api/v2', V3: 'https://api.topcoder-dev.com/v3', V5: 'https://api.topcoder-dev.com/v5', }, diff --git a/src/config/environments/environment.dev.config.ts b/src-ts/config/environments/environment.dev.config.ts similarity index 100% rename from src/config/environments/environment.dev.config.ts rename to src-ts/config/environments/environment.dev.config.ts diff --git a/src/config/environments/environment.prod.config.ts b/src-ts/config/environments/environment.prod.config.ts similarity index 87% rename from src/config/environments/environment.prod.config.ts rename to src-ts/config/environments/environment.prod.config.ts index e6705b611..1bb9d32d8 100644 --- a/src/config/environments/environment.prod.config.ts +++ b/src-ts/config/environments/environment.prod.config.ts @@ -7,12 +7,11 @@ import { EnvironmentConfigDefault } from './environment.default.config' export const EnvironmentConfigProd: GlobalConfig = { ...EnvironmentConfigDefault, API: { + FORUM_V2: 'https://vanilla.topcoder.com/api/v2', V3: 'https://api.topcoder.com/v3', V5: 'https://api.topcoder.com/v5', }, - DISABLED_TOOLS: [ - ToolTitle.designLib, - ], + DISABLED_TOOLS: [ ], ENV: AppHostEnvironment.prod, TAG_MANAGER_ID: 'GTM-MXXQHG8', URL: { diff --git a/src/config/environments/index.ts b/src-ts/config/environments/index.ts similarity index 100% rename from src/config/environments/index.ts rename to src-ts/config/environments/index.ts diff --git a/src/config/index.ts b/src-ts/config/index.ts similarity index 100% rename from src/config/index.ts rename to src-ts/config/index.ts diff --git a/src-ts/declarations.d.ts b/src-ts/declarations.d.ts new file mode 100644 index 000000000..81c5ad039 --- /dev/null +++ b/src-ts/declarations.d.ts @@ -0,0 +1,22 @@ +declare module '*.html' { + const htmlFile: string + export = htmlFile +} + +declare module '*.scss' { + const scssFile: { [style: string]: any } + export = scssFile +} + +declare module '*.svg' { + import * as React from 'react' + + export const ReactComponent: React.FunctionComponent & { title?: string }> + + const src: string + export default src +} + +declare module 'tc-auth-lib' diff --git a/src-ts/header/Header.module.scss b/src-ts/header/Header.module.scss new file mode 100644 index 000000000..d65fe3953 --- /dev/null +++ b/src-ts/header/Header.module.scss @@ -0,0 +1,27 @@ +@import '../lib/styles/includes'; + +.header-wrap { + display: block; + background-color: $tc-black; +} + +.header { + display: grid; + height: $header-height; + align-items: center; + width: 100%; + margin: 0 auto; + max-width: $xxl-min; + @include pagePaddings; + grid-template-columns: 78px 1fr auto; + + @include ltemd { + grid-template-columns: $header-height 1fr $header-height; + align-items: center; + } +} + +.subheader { + width: 100%; + position: relative; +} diff --git a/src-ts/header/Header.tsx b/src-ts/header/Header.tsx new file mode 100644 index 000000000..21d045913 --- /dev/null +++ b/src-ts/header/Header.tsx @@ -0,0 +1,22 @@ +import { FC } from 'react' + +import styles from './Header.module.scss' +import { Logo } from './logo' +import { ToolSelectors } from './tool-selectors' +import { UtilitySelectors } from './utility-selectors' + +const Header: FC<{}> = () => { + return ( +
+
+ + + + +
+
+
+ ) +} + +export default Header diff --git a/src/header/index.ts b/src-ts/header/index.ts similarity index 100% rename from src/header/index.ts rename to src-ts/header/index.ts diff --git a/src/header/logo/Logo.module.scss b/src-ts/header/logo/Logo.module.scss similarity index 72% rename from src/header/logo/Logo.module.scss rename to src-ts/header/logo/Logo.module.scss index cf8c40082..63f246fe1 100644 --- a/src/header/logo/Logo.module.scss +++ b/src-ts/header/logo/Logo.module.scss @@ -1,15 +1,16 @@ -@import '../../lib/styles'; +@import '../../lib/styles/includes'; .logo-no-link, .logo-link { display: flex; - flex-direction: column; - align-items: center; + + a { + display: flex; + } svg { width: calc($pad-xxl + $pad-xxxxl); height: $pad-xl; - @extend .pad-xxl; fill: none; path { @@ -20,10 +21,12 @@ padding: 0; } } + @include ltemd { + margin: 0 auto; + } } .logo-no-link { - a { cursor: default; } diff --git a/src/header/logo/Logo.tsx b/src-ts/header/logo/Logo.tsx similarity index 51% rename from src/header/logo/Logo.tsx rename to src-ts/header/logo/Logo.tsx index 01f0bd537..db850207a 100644 --- a/src/header/logo/Logo.tsx +++ b/src-ts/header/logo/Logo.tsx @@ -1,21 +1,26 @@ -import { FC } from 'react' +import { FC, useContext } from 'react' import { Link, useLocation } from 'react-router-dom' -import { LogoIcon, routeIsActive, routeRoot } from '../../lib' +import { LogoIcon, routeContext, RouteContextData } from '../../lib' import '../../lib/styles/index.scss' import styles from './Logo.module.scss' const Logo: FC<{}> = () => { + const { + isRootRoute, + rootLoggedInRoute, + }: RouteContextData = useContext(routeContext) + // the logo should be a link to the home page for all pages except the home page - const isLink: boolean = routeIsActive(useLocation().pathname, routeRoot) + const isLink: boolean = !isRootRoute(useLocation().pathname) return ( -
+
diff --git a/src/header/logo/index.ts b/src-ts/header/logo/index.ts similarity index 100% rename from src/header/logo/index.ts rename to src-ts/header/logo/index.ts diff --git a/src/header/tool-selectors/ToolSelectors.tsx b/src-ts/header/tool-selectors/ToolSelectors.tsx similarity index 100% rename from src/header/tool-selectors/ToolSelectors.tsx rename to src-ts/header/tool-selectors/ToolSelectors.tsx diff --git a/src/header/tool-selectors/index.ts b/src-ts/header/tool-selectors/index.ts similarity index 100% rename from src/header/tool-selectors/index.ts rename to src-ts/header/tool-selectors/index.ts diff --git a/src/header/tool-selectors/tool-selectors-narrow/ToolSelectorsNarrow.module.scss b/src-ts/header/tool-selectors/tool-selectors-narrow/ToolSelectorsNarrow.module.scss similarity index 79% rename from src/header/tool-selectors/tool-selectors-narrow/ToolSelectorsNarrow.module.scss rename to src-ts/header/tool-selectors/tool-selectors-narrow/ToolSelectorsNarrow.module.scss index 140e4d2b7..00432f9bd 100644 --- a/src/header/tool-selectors/tool-selectors-narrow/ToolSelectorsNarrow.module.scss +++ b/src-ts/header/tool-selectors/tool-selectors-narrow/ToolSelectorsNarrow.module.scss @@ -1,15 +1,14 @@ -@import '../../../lib/styles'; +@import '../../../lib/styles/includes'; .tool-selectors-narrow { display: none; - padding-left: $pad-xxl; @include ltemd { display: flex; } svg { - @include icon-xxl; + @include icon-xxxxl; fill: none; path { @@ -22,10 +21,11 @@ position: absolute; top: $header-height; left: 0; + bottom: 0; + height: $content-height; width: calc(100% - calc(2 * $pad-xxl)); z-index: 100; background-color: $black-100; padding: $pad-xxl; - @include content-height; } } diff --git a/src/header/tool-selectors/tool-selectors-narrow/ToolSelectorsNarrow.tsx b/src-ts/header/tool-selectors/tool-selectors-narrow/ToolSelectorsNarrow.tsx similarity index 77% rename from src/header/tool-selectors/tool-selectors-narrow/ToolSelectorsNarrow.tsx rename to src-ts/header/tool-selectors/tool-selectors-narrow/ToolSelectorsNarrow.tsx index 424f76a53..0e7c67021 100644 --- a/src/header/tool-selectors/tool-selectors-narrow/ToolSelectorsNarrow.tsx +++ b/src-ts/header/tool-selectors/tool-selectors-narrow/ToolSelectorsNarrow.tsx @@ -8,12 +8,18 @@ import styles from './ToolSelectorsNarrow.module.scss' const ToolSelectorsNarrow: FC<{}> = () => { - const { toolsRoutes }: RouteContextData = useContext(routeContext) + const { toolsRoutesForNav }: RouteContextData = useContext(routeContext) const [isOpen, setIsOpen]: [boolean, Dispatch>] = useState(false) + const toolSelectors: Array = toolsRoutesForNav + .map(route => ( + + )) + const closed: JSX.Element = - const toolSelectors: Array = toolsRoutes - .map(selector => ) const open: JSX.Element = ( <> diff --git a/src/header/tool-selectors/tool-selectors-narrow/index.ts b/src-ts/header/tool-selectors/tool-selectors-narrow/index.ts similarity index 100% rename from src/header/tool-selectors/tool-selectors-narrow/index.ts rename to src-ts/header/tool-selectors/tool-selectors-narrow/index.ts diff --git a/src/header/tool-selectors/tool-selectors-narrow/tool-selector-narrow/ToolSelectorNarrow.module.scss b/src-ts/header/tool-selectors/tool-selectors-narrow/tool-selector-narrow/ToolSelectorNarrow.module.scss similarity index 88% rename from src/header/tool-selectors/tool-selectors-narrow/tool-selector-narrow/ToolSelectorNarrow.module.scss rename to src-ts/header/tool-selectors/tool-selectors-narrow/tool-selector-narrow/ToolSelectorNarrow.module.scss index 3e864b7f0..aa0e9d6b0 100644 --- a/src/header/tool-selectors/tool-selectors-narrow/tool-selector-narrow/ToolSelectorNarrow.module.scss +++ b/src-ts/header/tool-selectors/tool-selectors-narrow/tool-selector-narrow/ToolSelectorNarrow.module.scss @@ -1,4 +1,4 @@ -@import '../../../../lib/styles'; +@import '../../../../lib/styles/includes'; .tool-selector-narrow { @@ -13,7 +13,7 @@ border-top: 1px solid $black-60; padding: $pad-lg 0; color: $tc-white; - @include font-weight-medium; + font-weight: $font-weight-medium; background-color: $black-100; svg { diff --git a/src-ts/header/tool-selectors/tool-selectors-narrow/tool-selector-narrow/ToolSelectorNarrow.tsx b/src-ts/header/tool-selectors/tool-selectors-narrow/tool-selector-narrow/ToolSelectorNarrow.tsx new file mode 100644 index 000000000..0aaf3e127 --- /dev/null +++ b/src-ts/header/tool-selectors/tool-selectors-narrow/tool-selector-narrow/ToolSelectorNarrow.tsx @@ -0,0 +1,43 @@ +import classNames from 'classnames' +import { FC, useContext } from 'react' +import { Link, useLocation } from 'react-router-dom' + +import { IconOutline, PlatformRoute, routeContext, RouteContextData } from '../../../../lib' + +import styles from './ToolSelectorNarrow.module.scss' + +interface ToolSelectorNarrowProps { + route: PlatformRoute +} + +const isParamRoute: (route: string) => boolean = (route: string) => !!route.match(/^:[^/]+$/) + +const ToolSelectorNarrow: FC = (props: ToolSelectorNarrowProps) => { + + const { + getPathFromRoute, + isActiveRoute, + }: RouteContextData = useContext(routeContext) + const toolRoute: PlatformRoute = props.route + const toolPath: string = getPathFromRoute(toolRoute) + + const baseClass: string = 'tool-selector-narrow' + const isActive: boolean = isActiveRoute(useLocation().pathname, toolPath) + const activeIndicaterClass: string = `${baseClass}-${isActive ? '' : 'in'}active` + const hasChildren: boolean = !!toolRoute.children.some(child => !!child.route && !isParamRoute(child.route)) + + return ( +
+ + {toolRoute.title} + {hasChildren && } + +
+ ) +} + +export default ToolSelectorNarrow diff --git a/src/header/tool-selectors/tool-selectors-narrow/tool-selector-narrow/index.ts b/src-ts/header/tool-selectors/tool-selectors-narrow/tool-selector-narrow/index.ts similarity index 100% rename from src/header/tool-selectors/tool-selectors-narrow/tool-selector-narrow/index.ts rename to src-ts/header/tool-selectors/tool-selectors-narrow/tool-selector-narrow/index.ts diff --git a/src/header/tool-selectors/tool-selectors-wide/ToolSelectorsWide.module.scss b/src-ts/header/tool-selectors/tool-selectors-wide/ToolSelectorsWide.module.scss similarity index 82% rename from src/header/tool-selectors/tool-selectors-wide/ToolSelectorsWide.module.scss rename to src-ts/header/tool-selectors/tool-selectors-wide/ToolSelectorsWide.module.scss index f7793e2a1..ba01f4de5 100644 --- a/src/header/tool-selectors/tool-selectors-wide/ToolSelectorsWide.module.scss +++ b/src-ts/header/tool-selectors/tool-selectors-wide/ToolSelectorsWide.module.scss @@ -1,4 +1,4 @@ -@import '../../../lib/styles/'; +@import '../../../lib/styles/includes'; .tool-selectors-wide { display: flex; diff --git a/src-ts/header/tool-selectors/tool-selectors-wide/ToolSelectorsWide.tsx b/src-ts/header/tool-selectors/tool-selectors-wide/ToolSelectorsWide.tsx new file mode 100644 index 000000000..47a1633a3 --- /dev/null +++ b/src-ts/header/tool-selectors/tool-selectors-wide/ToolSelectorsWide.tsx @@ -0,0 +1,27 @@ +import { FC, useContext } from 'react' + +import { routeContext, RouteContextData } from '../../../lib' + +import { ToolSelectorWide } from './tool-selector-wide' +import styles from './ToolSelectorsWide.module.scss' + +const ToolSelectorsWide: FC<{}> = () => { + + const { toolsRoutesForNav }: RouteContextData = useContext(routeContext) + + const selectors: Array = toolsRoutesForNav + .map(route => ( + + )) + + return ( +
+ {selectors} +
+ ) +} + +export default ToolSelectorsWide diff --git a/src/header/tool-selectors/tool-selectors-wide/index.ts b/src-ts/header/tool-selectors/tool-selectors-wide/index.ts similarity index 100% rename from src/header/tool-selectors/tool-selectors-wide/index.ts rename to src-ts/header/tool-selectors/tool-selectors-wide/index.ts diff --git a/src/header/tool-selectors/tool-selectors-wide/tool-selector-wide/ToolSelectorWide.module.scss b/src-ts/header/tool-selectors/tool-selectors-wide/tool-selector-wide/ToolSelectorWide.module.scss similarity index 81% rename from src/header/tool-selectors/tool-selectors-wide/tool-selector-wide/ToolSelectorWide.module.scss rename to src-ts/header/tool-selectors/tool-selectors-wide/tool-selector-wide/ToolSelectorWide.module.scss index 4d4470f88..64a8efdee 100644 --- a/src/header/tool-selectors/tool-selectors-wide/tool-selector-wide/ToolSelectorWide.module.scss +++ b/src-ts/header/tool-selectors/tool-selectors-wide/tool-selector-wide/ToolSelectorWide.module.scss @@ -1,4 +1,4 @@ -@import '../../../../lib/styles/'; +@import '../../../../lib/styles/includes'; .tool-selector-wide { padding-right: 0; @@ -19,6 +19,13 @@ height: 2px; } + &.tool-selector-wide-is-link { + + a { + cursor: pointer !important; + } + } + &.tool-selector-wide-active { a { diff --git a/src-ts/header/tool-selectors/tool-selectors-wide/tool-selector-wide/ToolSelectorWide.tsx b/src-ts/header/tool-selectors/tool-selectors-wide/tool-selector-wide/ToolSelectorWide.tsx new file mode 100644 index 000000000..e7cb78a9a --- /dev/null +++ b/src-ts/header/tool-selectors/tool-selectors-wide/tool-selector-wide/ToolSelectorWide.tsx @@ -0,0 +1,54 @@ +import classNames from 'classnames' +import { FC, useContext } from 'react' +import { Link, useLocation } from 'react-router-dom' + +import { + PlatformRoute, + routeContext, + RouteContextData, +} from '../../../../lib' +import '../../../../lib/styles/index.scss' + +import styles from './ToolSelectorWide.module.scss' + +interface ToolSelectorWideProps { + route: PlatformRoute +} + +const ToolSelectorWide: FC = (props: ToolSelectorWideProps) => { + + const { + getPathFromRoute, + isActiveRoute, + isRootRoute, + }: RouteContextData = useContext(routeContext) + const activePath: string = useLocation().pathname + const toolRoute: PlatformRoute = props.route + const toolPath: string = getPathFromRoute(toolRoute) + + const isActive: boolean = isActiveRoute(activePath, toolPath) + + const activeIndicatorClass: string = `tool-selector-wide-${isActive ? '' : 'in'}active` + + // the tool link should be usable for all active routes except the home page + const isLink: boolean = isActive && !isRootRoute(activePath) + + return ( +
+ + {toolRoute.title} + +
+
+ ) +} + +export default ToolSelectorWide diff --git a/src/header/tool-selectors/tool-selectors-wide/tool-selector-wide/index.ts b/src-ts/header/tool-selectors/tool-selectors-wide/tool-selector-wide/index.ts similarity index 100% rename from src/header/tool-selectors/tool-selectors-wide/tool-selector-wide/index.ts rename to src-ts/header/tool-selectors/tool-selectors-wide/tool-selector-wide/index.ts diff --git a/src/header/utility-selectors/UtilitySelector/ProfileSelector/ProfileSelector.module.scss b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/ProfileSelector.module.scss similarity index 68% rename from src/header/utility-selectors/UtilitySelector/ProfileSelector/ProfileSelector.module.scss rename to src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/ProfileSelector.module.scss index ffe020ee3..3e3c382d3 100644 --- a/src/header/utility-selectors/UtilitySelector/ProfileSelector/ProfileSelector.module.scss +++ b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/ProfileSelector.module.scss @@ -1,7 +1,7 @@ -@import '../../../../lib/styles'; +@import '../../../../lib/styles/includes'; .profile-selector { display: flex; justify-content: flex-end; align-items: center; -} \ No newline at end of file +} diff --git a/src/header/utility-selectors/UtilitySelector/ProfileSelector/ProfileSelector.tsx b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/ProfileSelector.tsx similarity index 100% rename from src/header/utility-selectors/UtilitySelector/ProfileSelector/ProfileSelector.tsx rename to src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/ProfileSelector.tsx diff --git a/src/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/ProfileLoggedIn.module.scss b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/ProfileLoggedIn.module.scss similarity index 65% rename from src/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/ProfileLoggedIn.module.scss rename to src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/ProfileLoggedIn.module.scss index 5b5626e41..f0906e8b4 100644 --- a/src/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/ProfileLoggedIn.module.scss +++ b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/ProfileLoggedIn.module.scss @@ -1,6 +1,6 @@ -@import '../../../../../lib/styles'; +@import '../../../../../lib/styles/includes'; -$overlaySquare: calc($pad-xxxxl + 2 * $border); +$overlaySquare: $pad-xxxxl; .profile-avatar, .overlay { @@ -9,14 +9,15 @@ $overlaySquare: calc($pad-xxxxl + 2 * $border); .overlay { position: absolute; + top: 0; + left: 0; z-index: 100; background: $black-100-opacity-80; display: flex; justify-content: center; align-items: center; - height: $overlaySquare; - width: $overlaySquare; - margin-top: calc($overlaySquare * -1); + height: 100%; + width: 100%; border-radius: 50%; svg { @@ -24,4 +25,8 @@ $overlaySquare: calc($pad-xxxxl + 2 * $border); stroke-width: $border; @include icon-xxl; } -} \ No newline at end of file +} + +.profile-avatar { + position: relative; +} diff --git a/src/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/ProfileLoggedIn.tsx b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/ProfileLoggedIn.tsx similarity index 57% rename from src/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/ProfileLoggedIn.tsx rename to src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/ProfileLoggedIn.tsx index b8d0fc6bb..26ccab110 100644 --- a/src/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/ProfileLoggedIn.tsx +++ b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/ProfileLoggedIn.tsx @@ -1,13 +1,12 @@ -import { Dispatch, FC, SetStateAction, useContext, useState } from 'react' +import { Dispatch, FC, MutableRefObject, SetStateAction, useCallback, useContext, useRef, useState } from 'react' import { Avatar, - ComponentVisible, IconOutline, logInfo, profileContext, ProfileContextData, - useHideClickOutside, + useClickOutside, } from '../../../../../lib' import { ProfilePanel } from './profile-panel' @@ -20,33 +19,26 @@ interface ProfileLoggedInProps { const ProfileLoggedIn: FC = (props: ProfileLoggedInProps) => { const { profile }: ProfileContextData = useContext(profileContext) - const [profilePanelOpen, setProfilePanelOpen]: [boolean, Dispatch>] = useState(false) - - const { - isComponentVisible, - ref, - setIsComponentVisible, - }: ComponentVisible = useHideClickOutside(false) if (!profile) { logInfo('tried to render the logged in profile w/out a profile') return <> } - function toggleProfilePanel(): void { - const toggleTo: boolean = !profilePanelOpen - setProfilePanelOpen(toggleTo) - setIsComponentVisible(toggleTo) - } + const triggerRef: MutableRefObject = useRef(undefined) + const [profilePanelOpen, setProfilePanelOpen]: [boolean, Dispatch>] = useState(false) - if (!isComponentVisible && profilePanelOpen) { - setProfilePanelOpen(isComponentVisible) - } + const toggleProfilePanel: () => void = useCallback(() => { + setProfilePanelOpen((isOpen: boolean) => !isOpen) + }, []) + + useClickOutside(triggerRef.current, () => setProfilePanelOpen(false)) return ( <>
= (props: ProfileLoggedInProps) size='sm' /> {profilePanelOpen && ( -
- -
+ <> +
+ +
+ + )}
- {profilePanelOpen && ( - - )} ) } diff --git a/src/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/index.ts b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/index.ts similarity index 100% rename from src/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/index.ts rename to src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/index.ts diff --git a/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/profile-panel/ProfilePanel.module.scss b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/profile-panel/ProfilePanel.module.scss new file mode 100644 index 000000000..4082c4761 --- /dev/null +++ b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/profile-panel/ProfilePanel.module.scss @@ -0,0 +1,59 @@ +@use '../../../../../../lib/styles/typography'; +@import '../../../../../../lib/styles/includes'; + +$arrowTipIconHeight: 9px; + +.profile-panel { + @extend .body-ultra-small; + position: absolute; + z-index: 1000; + top: calc($pad-xxxxl + 4px + $arrowTipIconHeight); + right: calc(-1 * $pad-xxxxl / 2); + width: 168px; + display: flex; + flex-direction: column; + filter: drop-shadow(0px 1px 5px rgba(0, 0, 0, 0.2)); + + background-color: $tc-white; + color: $black-100; + border-radius: $pad-sm; + + padding: $pad-sm $pad-lg $pad-lg; + + .arrow-tip { + display: flex; + position: absolute; + top: -1 * $arrowTipIconHeight; + right: 15px; + svg { + path { + fill: $tc-white; + } + } + } + + .handle { + @extend .medium-subtitle; + } + + hr { + margin: $pad-sm 0; + } + + .nav-item { + display: flex; + cursor: pointer; + align-items: center; + gap: 4px; + + .nav-item { + margin-top: $pad-sm; + } + + .icon { + display: flex; + svg { + @include icon-lg; + } + } + } +} diff --git a/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/profile-panel/ProfilePanel.tsx b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/profile-panel/ProfilePanel.tsx new file mode 100644 index 000000000..d3204f3fd --- /dev/null +++ b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/profile-panel/ProfilePanel.tsx @@ -0,0 +1,77 @@ +import classNames from 'classnames' +import { FC, useContext } from 'react' +import { NavigateFunction, useNavigate } from 'react-router-dom' + +import { + authUrlLogout, + IconOutline, + profileContext, + ProfileContextData, + routeContext, + RouteContextData, + TooltipArrowIcon, +} from '../../../../../../lib' + +import styles from './ProfilePanel.module.scss' + +interface ProfilePanelProps { + settingsTitle: string +} + +const ProfilePanel: FC = (props: ProfilePanelProps) => { + + const { profile }: ProfileContextData = useContext(profileContext) + const { + getPath, + rootLoggedOutRoute, + }: RouteContextData = useContext(routeContext) + + const navigate: NavigateFunction = useNavigate() + + if (!profile) { + // this should never happen + return <> + } + + function goToAccount(): void { + navigate(getPath(props.settingsTitle)) + } + + const name: string = `${profile.firstName} ${profile.lastName?.substring(0, 1)}${!!profile.lastName ? '.' : undefined}` + + return ( +
+
+ +
+
+ {name} +
+
+
+
+ + + + + {props.settingsTitle} + +
+ + + + + Log Out + +
+
+ ) +} + +export default ProfilePanel diff --git a/src/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/profile-panel/index.ts b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/profile-panel/index.ts similarity index 100% rename from src/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/profile-panel/index.ts rename to src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/profile-panel/index.ts diff --git a/src/header/utility-selectors/UtilitySelector/ProfileSelector/profile-not-logged-in/ProfileNotLoggedIn.module.scss b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-not-logged-in/ProfileNotLoggedIn.module.scss similarity index 74% rename from src/header/utility-selectors/UtilitySelector/ProfileSelector/profile-not-logged-in/ProfileNotLoggedIn.module.scss rename to src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-not-logged-in/ProfileNotLoggedIn.module.scss index 53df6641c..47fb16585 100644 --- a/src/header/utility-selectors/UtilitySelector/ProfileSelector/profile-not-logged-in/ProfileNotLoggedIn.module.scss +++ b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-not-logged-in/ProfileNotLoggedIn.module.scss @@ -1,4 +1,4 @@ -@import '../../../../../lib/styles'; +@import '../../../../../lib/styles/includes'; @include ltemd { .login, @@ -9,4 +9,4 @@ display: inline; } } -} \ No newline at end of file +} diff --git a/src/header/utility-selectors/UtilitySelector/ProfileSelector/profile-not-logged-in/ProfileNotLoggedIn.tsx b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-not-logged-in/ProfileNotLoggedIn.tsx similarity index 60% rename from src/header/utility-selectors/UtilitySelector/ProfileSelector/profile-not-logged-in/ProfileNotLoggedIn.tsx rename to src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-not-logged-in/ProfileNotLoggedIn.tsx index 9edd0a6d7..255ccba68 100644 --- a/src/header/utility-selectors/UtilitySelector/ProfileSelector/profile-not-logged-in/ProfileNotLoggedIn.tsx +++ b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-not-logged-in/ProfileNotLoggedIn.tsx @@ -1,29 +1,31 @@ -import { FC } from 'react' +import { FC, useContext } from 'react' -import { authUrlLogin, authUrlSignup, Button, routeRoot } from '../../../../../lib' +import { authUrlLogin, authUrlSignup, Button, routeContext, RouteContextData } from '../../../../../lib' import '../../../../../lib/styles/index.scss' import styles from './ProfileNotLoggedIn.module.scss' const ProfileNotLoggedIn: FC<{}> = () => { + const { rootLoggedInRoute }: RouteContextData = useContext(routeContext) + return ( <> + {content} + ) } function getButtonClasses(props: ButtonProps): string { const classes: string = classNames( - styles.button, + 'button', props.className, - !!props.buttonStyle ? styles[props.buttonStyle] : styles.primary, - styles[`button-${props.size || 'md'}`], - !!props.disable ? styles.disabled : undefined + props.buttonStyle || 'primary', + `button-${props.size || 'md'}`, + !!props.disable ? 'disabled' : undefined ) return classes } +function getButtonContent(props: ButtonProps): JSX.Element { + + // if this is a link, just add the label and the arrow icon + if (props.buttonStyle === 'link') { + return ( + <> + {props.label} + + + ) + } + + const Icon: FC> | undefined = props.icon + return ( + <> + {!!Icon && } + {props.label} + + ) +} + function getClickHandler(props: ButtonProps): (event?: any) => void { return props.onClick || (() => undefined) } diff --git a/src/lib/button/index.ts b/src-ts/lib/button/index.ts similarity index 88% rename from src/lib/button/index.ts rename to src-ts/lib/button/index.ts index b5b2b24df..4d9c7c186 100644 --- a/src/lib/button/index.ts +++ b/src-ts/lib/button/index.ts @@ -1,4 +1,5 @@ export { default as Button } from './Button' +export { type ButtonProps } from './Button' export // tslint:disable-next-line: no-unused-expression type { ButtonSize } from './Button' diff --git a/src/lib/card/Card.tsx b/src-ts/lib/card/Card.tsx similarity index 51% rename from src/lib/card/Card.tsx rename to src-ts/lib/card/Card.tsx index f5faf01a8..b8333d8a3 100644 --- a/src/lib/card/Card.tsx +++ b/src-ts/lib/card/Card.tsx @@ -1,10 +1,14 @@ +import classNames from 'classnames' import { FC, ReactNode, SVGProps } from 'react' -import styles from './Card.module.scss' +import { ButtonStyle } from '../button' +import '../styles/index.scss' export interface CardProps { + buttonStyle?: ButtonStyle children: ReactNode icon?: FC> + onClick?: () => void title: string } @@ -13,12 +17,15 @@ const Card: FC = (props: CardProps) => { const Icon: FC> | undefined = props.icon return ( -
+
-
-
+
+

{props.title} -

+ {!!Icon && }
diff --git a/src/lib/card/index.ts b/src-ts/lib/card/index.ts similarity index 100% rename from src/lib/card/index.ts rename to src-ts/lib/card/index.ts diff --git a/src-ts/lib/contact-support-form/ContactSupportForm.module.scss b/src-ts/lib/contact-support-form/ContactSupportForm.module.scss new file mode 100644 index 000000000..f1fa4cb87 --- /dev/null +++ b/src-ts/lib/contact-support-form/ContactSupportForm.module.scss @@ -0,0 +1,8 @@ +@import '../styles/includes'; + +.contact-support-intro { + + p { + margin-bottom: $pad-lg; + } +} diff --git a/src-ts/lib/contact-support-form/ContactSupportForm.tsx b/src-ts/lib/contact-support-form/ContactSupportForm.tsx new file mode 100644 index 000000000..0580bf918 --- /dev/null +++ b/src-ts/lib/contact-support-form/ContactSupportForm.tsx @@ -0,0 +1,75 @@ +import { FC, useContext } from 'react' + +import { Form, FormDefinition, formGetInputModel, FormInputModel } from '../form' +import { profileContext, ProfileContextData } from '../profile-provider' + +import { ContactSupportFormField } from './contact-support-form.config' +import { ContactSupportRequest } from './contact-support-functions' +import { contactSupportSubmitRequestAsync } from './contact-support-functions/contact-support-store' +import styles from './ContactSupportForm.module.scss' + +export interface ContactSupportFormProps { + formDef: FormDefinition + onSave: () => void + workId?: string +} + +const ContactSupportForm: FC = (props: ContactSupportFormProps) => { + + const { profile }: ProfileContextData = useContext(profileContext) + + function generateRequest(inputs: ReadonlyArray): ContactSupportRequest { + const firstName: string = formGetInputModel(inputs, ContactSupportFormField.first).value as string + const lastName: string = formGetInputModel(inputs, ContactSupportFormField.last).value as string + const email: string = formGetInputModel(inputs, ContactSupportFormField.email).value as string + const question: string = formGetInputModel(inputs, ContactSupportFormField.question).value as string + return { + challengeId: props.workId, + email, + firstName, + isSelfService: true, + lastName, + question, + } + } + + async function saveAsync(request: ContactSupportRequest): Promise { + return contactSupportSubmitRequestAsync(request) + .then(() => { + props.onSave() + }) + } + + const emailElement: JSX.Element | undefined = !!profile?.email + ? ( + <> +  at {profile.email} + + ) + : undefined + + return ( + <> +
+

+ Hi {profile?.firstName || 'there'}, we're here to help. +

+

+ Please describe what you'd like to discuss, and a + Topcoder Solutions Expert will email you back + {emailElement} +  within one business day. +

+
+ +
+ + ) +} + +export default ContactSupportForm diff --git a/src-ts/lib/contact-support-form/contact-support-form.config.ts b/src-ts/lib/contact-support-form/contact-support-form.config.ts new file mode 100644 index 000000000..358990d84 --- /dev/null +++ b/src-ts/lib/contact-support-form/contact-support-form.config.ts @@ -0,0 +1,66 @@ +import { FormDefinition, validatorEmail, validatorRequired } from '../form' + +export enum ContactSupportFormField { + email = 'email', + first = 'firstName', + last = 'lastName', + question = 'question', +} + +export const contactSupportFormDef: FormDefinition = { + buttons: [ + { + buttonStyle: 'secondary', + isSave: true, + label: 'Submit', + size: 'lg', + type: 'submit', + }, + ], + inputs: [ + { + label: 'First Name', + name: ContactSupportFormField.first, + type: 'text', + validators: [ + { + validator: validatorRequired, + }, + ], + }, + { + label: 'Last Name', + name: ContactSupportFormField.last, + type: 'text', + validators: [ + { + validator: validatorRequired, + }, + ], + }, + { + label: 'Email', + name: ContactSupportFormField.email, + type: 'text', + validators: [ + { + validator: validatorEmail, + }, + { + validator: validatorRequired, + }, + ], + }, + { + label: 'How can we help you?', + name: ContactSupportFormField.question, + type: 'textarea', + validators: [ + { + validator: validatorRequired, + }, + ], + }, + ], + successMessage: 'Your request has been submitted.', +} diff --git a/src-ts/lib/contact-support-form/contact-support-functions/contact-support-store/contact-support-request.model.ts b/src-ts/lib/contact-support-form/contact-support-functions/contact-support-store/contact-support-request.model.ts new file mode 100644 index 000000000..b399da148 --- /dev/null +++ b/src-ts/lib/contact-support-form/contact-support-functions/contact-support-store/contact-support-request.model.ts @@ -0,0 +1,8 @@ +export interface ContactSupportRequest { + challengeId?: string + email: string + firstName: string + isSelfService: boolean + lastName: string + question: string +} diff --git a/src-ts/lib/contact-support-form/contact-support-functions/contact-support-store/contact-support.store.ts b/src-ts/lib/contact-support-form/contact-support-functions/contact-support-store/contact-support.store.ts new file mode 100644 index 000000000..a1f72db8f --- /dev/null +++ b/src-ts/lib/contact-support-form/contact-support-functions/contact-support-store/contact-support.store.ts @@ -0,0 +1,9 @@ +import { EnvironmentConfig } from '../../../../config' +import { xhrPostAsync } from '../../../functions' + +import { ContactSupportRequest } from './contact-support-request.model' + +export async function submitRequestAsync(request: ContactSupportRequest): Promise { + const url: string = `${EnvironmentConfig.API.V5}/challenges/support-requests` + await xhrPostAsync(url, request) +} diff --git a/src-ts/lib/contact-support-form/contact-support-functions/contact-support-store/index.ts b/src-ts/lib/contact-support-form/contact-support-functions/contact-support-store/index.ts new file mode 100644 index 000000000..964ff4620 --- /dev/null +++ b/src-ts/lib/contact-support-form/contact-support-functions/contact-support-store/index.ts @@ -0,0 +1,2 @@ +export * from './contact-support-request.model' +export { submitRequestAsync as contactSupportSubmitRequestAsync } from './contact-support.store' diff --git a/src-ts/lib/contact-support-form/contact-support-functions/contact-support.functions.ts b/src-ts/lib/contact-support-form/contact-support-functions/contact-support.functions.ts new file mode 100644 index 000000000..96a79386e --- /dev/null +++ b/src-ts/lib/contact-support-form/contact-support-functions/contact-support.functions.ts @@ -0,0 +1,5 @@ +import { ContactSupportRequest, contactSupportSubmitRequestAsync } from './contact-support-store' + +export async function submitRequestAsync(request: ContactSupportRequest): Promise { + return contactSupportSubmitRequestAsync(request) +} diff --git a/src-ts/lib/contact-support-form/contact-support-functions/index.ts b/src-ts/lib/contact-support-form/contact-support-functions/index.ts new file mode 100644 index 000000000..7e1724cff --- /dev/null +++ b/src-ts/lib/contact-support-form/contact-support-functions/index.ts @@ -0,0 +1,2 @@ +export { type ContactSupportRequest } from './contact-support-store' +export { submitRequestAsync as contactSupportSubmitRequestAsync } from './contact-support.functions' diff --git a/src-ts/lib/contact-support-form/index.ts b/src-ts/lib/contact-support-form/index.ts new file mode 100644 index 000000000..47c0aff74 --- /dev/null +++ b/src-ts/lib/contact-support-form/index.ts @@ -0,0 +1,2 @@ +export { default as ContactSupportForm } from './ContactSupportForm' +export { contactSupportFormDef } from './contact-support-form.config' diff --git a/src-ts/lib/content-layout/ContentLayout.module.scss b/src-ts/lib/content-layout/ContentLayout.module.scss new file mode 100644 index 000000000..0e111a1d1 --- /dev/null +++ b/src-ts/lib/content-layout/ContentLayout.module.scss @@ -0,0 +1,39 @@ +@import '../styles/includes'; + +.content { + padding-top: 0; + padding-bottom: $pad-lg; + + flex: 99 1 auto; + display: flex; + + .content-outer { + max-width: $xxl-min; + @include pagePaddings; + margin: 0 auto; + width: 100%; + display: flex; + + .content-inner { + margin: 0 auto; + flex: 1; + max-width: $xxl-min; + display: flex; + flex-direction: column; + overflow: auto; + + .page-header { + display: flex; + align-items: center; + border-bottom: $border solid $black-5; + margin-top: $pad-sm; + padding: $pad-xxl 0; + + h1 { + flex: 1; + color: $black-100; + } + } + } + } +} diff --git a/src/lib/content-layout/ContentLayout.tsx b/src-ts/lib/content-layout/ContentLayout.tsx similarity index 65% rename from src/lib/content-layout/ContentLayout.tsx rename to src-ts/lib/content-layout/ContentLayout.tsx index 100be616a..c26eb50d0 100644 --- a/src/lib/content-layout/ContentLayout.tsx +++ b/src-ts/lib/content-layout/ContentLayout.tsx @@ -1,11 +1,13 @@ import classNames from 'classnames' import { FC, ReactNode } from 'react' +import { Button, ButtonProps } from '../button' import '../styles/index.scss' import styles from './ContentLayout.module.scss' export interface ContentLayoutProps { + buttonConfig?: ButtonProps children?: ReactNode contentClass?: string title: string @@ -13,7 +15,6 @@ export interface ContentLayoutProps { } const ContentLayout: FC = (props: ContentLayoutProps) => { - return (
@@ -25,6 +26,16 @@ const ContentLayout: FC = (props: ContentLayoutProps) => {

{props.title}

+ {!!props.buttonConfig && ( +
+
+ )} +
{props.children} diff --git a/src/lib/content-layout/index.ts b/src-ts/lib/content-layout/index.ts similarity index 100% rename from src/lib/content-layout/index.ts rename to src-ts/lib/content-layout/index.ts diff --git a/src-ts/lib/form/Form.module.scss b/src-ts/lib/form/Form.module.scss new file mode 100644 index 000000000..2fc69ee93 --- /dev/null +++ b/src-ts/lib/form/Form.module.scss @@ -0,0 +1,55 @@ +@use '../styles/typography'; +@import '../styles/includes'; + +.form { + flex: 1; + display: flex; + flex-direction: column; + width: 100%; + + @include gtemd { + width: auto; + } + + .subtitle { + margin-bottom: $pad-lg; + } + + .form-footer { + display: flex; + flex-direction: column; + width: 100%; + margin-top: auto; + margin-bottom: -#{$pad-md}; + padding-top: $pad-xs; + border-top: 1px solid $black-10; + + @include gtemd { + margin: $pad-xl 0 0; + padding-top: $pad-xxxl; + } + + .button-container { + display: flex; + justify-content: flex-end; + } + + .form-error { + display: flex; + @extend .ultra-small-medium; + color: $red-100; + background-color: $black-5; + padding: $pad-xs; + margin-bottom: $pad-xl; + + svg { + @include icon-lg; + margin-right: $pad-xs; + } + } + + button:last-child { + margin-right: 0; + } + } +} diff --git a/src/lib/form/Form.tsx b/src-ts/lib/form/Form.tsx similarity index 51% rename from src/lib/form/Form.tsx rename to src-ts/lib/form/Form.tsx index 3e6ff3646..e618e63ae 100644 --- a/src/lib/form/Form.tsx +++ b/src-ts/lib/form/Form.tsx @@ -1,16 +1,27 @@ -import { Dispatch, FocusEvent, FormEvent, SetStateAction, useState } from 'react' +import classNames from 'classnames' +import { + ChangeEvent, + createRef, + Dispatch, + FocusEvent, + FormEvent, + RefObject, + SetStateAction, + useEffect, + useState, +} from 'react' import { Button } from '../button' import '../styles/index.scss' +import { IconOutline } from '../svgs' import { FormDefinition } from './form-definition.model' import { - FormErrorMessage, - formGetInputModel, formInitializeValues, + formOnBlur, formOnChange, - formReset, - formSubmitAsync, + formOnReset, + formOnSubmitAsync, } from './form-functions' import { FormInputModel } from './form-input.model' import { FormInputs } from './form-inputs' @@ -19,67 +30,52 @@ import styles from './Form.module.scss' interface FormProps { readonly formDef: FormDefinition readonly formValues?: ValueType + readonly onSuccess?: () => void readonly requestGenerator: (inputs: ReadonlyArray) => RequestType - readonly resetOnError: boolean readonly save: (value: RequestType) => Promise - readonly succeeded?: () => void } const Form: (props: FormProps) => JSX.Element = (props: FormProps) => { - const [disableSave, setDisableSave]: [boolean, Dispatch>] - = useState(false) - const [formDef, setFormDef]: [FormDefinition, Dispatch>] = useState({ ...props.formDef }) + const [formError, setFormError]: [string | undefined, Dispatch>] + = useState() + const [formKey, setFormKey]: [number, Dispatch>] = useState(Date.now()) - function onBlur(event: FocusEvent): void { - const inputDef: FormInputModel = formGetInputModel(props.formDef.inputs, event.target.name) - inputDef.validateOnBlur - ?.forEach(validator => { - if (!inputDef.error) { - inputDef.error = validator(event.target.value, event.target.form?.elements, inputDef.dependentField) - setFormDef({ ...formDef }) - } - }) - } + const [formRef]: [RefObject, Dispatch>>] + = useState>(createRef()) - async function onChange(event: FormEvent): Promise { - const isValid: boolean = await formOnChange(event, formDef.inputs) + function onBlur(event: FocusEvent): void { + formOnBlur(event, formDef.inputs, props.formValues) setFormDef({ ...formDef }) - setDisableSave(!isValid) } - function onFocus(event: FocusEvent): void { - const inputDef: FormInputModel = formGetInputModel(props.formDef.inputs, event.target.name) - inputDef.touched = true + function onChange(event: ChangeEvent): void { + formOnChange(event, formDef.inputs, props.formValues) setFormDef({ ...formDef }) } function onReset(): void { + formOnReset(formDef.inputs, props.formValues) setFormDef({ ...formDef }) - formReset(props.formDef.inputs, props.formValues) setFormKey(Date.now()) } - async function onSubmit(event: FormEvent): Promise { + async function onSubmitAsync(event: FormEvent): Promise { const values: RequestType = props.requestGenerator(formDef.inputs) - formSubmitAsync(event, formDef.inputs, props.formDef.title || 'data', values, props.save, setDisableSave, props.succeeded) + formOnSubmitAsync(event, formDef, values, props.save, props.onSuccess) .then(() => { setFormKey(Date.now()) - formReset(formDef.inputs, props.formValues) + formOnReset(formDef.inputs, props.formValues) setFormDef({ ...formDef }) }) - .catch((error: FormErrorMessage) => { - // only reset on save errors - if (props.resetOnError && error === FormErrorMessage.save) { - formReset(formDef.inputs, props.formValues) - setFormKey(Date.now()) - } + .catch((error: string | undefined) => { + setFormError(error) setFormDef({ ...formDef }) }) } @@ -98,37 +94,60 @@ const Form: (props: FormProps ) }) + // set the max width of the form error so that it doesn't push the width of the form wider + const errorsRef: RefObject = createRef() + useEffect(() => { + const formWidth: number = formRef.current?.clientWidth || 0 + + // errorsRef current will always exist, + // but need to to satisfy typescript and check + if (!!errorsRef.current) { + errorsRef.current.style.maxWidth = `${formWidth}px` + } + }, [formRef]) + return ( {!!props.formDef.title && ( - <> -

{props.formDef.title}

-
- +

{props.formDef.title}

+ )} + + {!!props.formDef.subtitle && ( +
+ {props.formDef.subtitle} +
)} -
-
+
+ {!!formError && ( +
+ + {formError} +
+ )} +
{buttons}
diff --git a/src/lib/form/form-button.model.ts b/src-ts/lib/form/form-button.model.ts similarity index 100% rename from src/lib/form/form-button.model.ts rename to src-ts/lib/form/form-button.model.ts diff --git a/src/lib/form/form-definition.model.ts b/src-ts/lib/form/form-definition.model.ts similarity index 75% rename from src/lib/form/form-definition.model.ts rename to src-ts/lib/form/form-definition.model.ts index 2475d6f66..a331a33be 100644 --- a/src/lib/form/form-definition.model.ts +++ b/src-ts/lib/form/form-definition.model.ts @@ -4,6 +4,9 @@ import { FormInputModel } from './form-input.model' export interface FormDefinition { readonly buttons: ReadonlyArray readonly inputs: ReadonlyArray + readonly shortName?: string + readonly subtitle?: string + readonly successMessage?: string readonly tabIndexStart?: number readonly title?: string } diff --git a/src-ts/lib/form/form-functions/form.functions.ts b/src-ts/lib/form/form-functions/form.functions.ts new file mode 100644 index 000000000..32e38bf4a --- /dev/null +++ b/src-ts/lib/form/form-functions/form.functions.ts @@ -0,0 +1,165 @@ +import { ChangeEvent, FormEvent } from 'react' +import { toast } from 'react-toastify' + +import { FormDefinition } from '../form-definition.model' +import { FormInputModel } from '../form-input.model' + +export function getInputElement(formElements: HTMLFormControlsCollection, fieldName: string): HTMLInputElement { + return formElements.namedItem(fieldName) as HTMLInputElement +} + +export function getInputModel(inputs: ReadonlyArray, fieldName: string): FormInputModel { + + const formField: FormInputModel | undefined = inputs.find(input => input.name === fieldName) + + // if we can't find the input we have a problem + if (!formField) { + throw new Error(`There is no input definition for the ${fieldName} field`) + } + + return formField +} + +export function initializeValues(inputs: ReadonlyArray, formValues?: T): void { + inputs + .filter(input => !input.dirty && !input.touched) + .forEach(input => { + input.value = !!(formValues as any)?.hasOwnProperty(input.name) + ? (formValues as any)[input.name] + : undefined + }) +} + +export function onBlur(event: FormEvent, inputs: ReadonlyArray, formValues?: T): void { + handleFieldEvent(event.target as HTMLInputElement | HTMLTextAreaElement, inputs, 'blur', formValues) +} + +export function onChange(event: ChangeEvent, inputs: ReadonlyArray, formValues?: T): void { + handleFieldEvent(event.target as HTMLInputElement | HTMLTextAreaElement, inputs, 'change', formValues) +} + +export function onReset(inputs: ReadonlyArray, formValue?: any): void { + inputs + .forEach(inputDef => { + inputDef.dirty = false + inputDef.touched = false + inputDef.error = undefined + inputDef.value = formValue?.[inputDef.name] + }) +} + +export async function onSubmitAsync( + event: FormEvent, + formDef: FormDefinition, + formValue: T, + save: (value: T) => Promise, + onSuccess?: () => void, +): Promise { + + event.preventDefault() + + const { inputs, shortName, successMessage }: FormDefinition = formDef + + // get the dirty fields before we validate b/c validation marks them dirty on submit + const dirty: FormInputModel | undefined = inputs.find(fieldDef => !!fieldDef.dirty) + + // if there are any validation errors, display a message and stop submitting + // NOTE: need to check this before we check if the form is dirty bc you + // could have a form that's not dirty but has errors and you wouldn't + // want to have it look like the submit succeeded + const formValues: HTMLFormControlsCollection = (event.target as HTMLFormElement).elements + const isValid: boolean = validateForm(inputs, formValues, 'submit') + if (!isValid) { + return Promise.reject() + } + + // set the properties for the updated T value + inputs.forEach(field => (formValue as any)[field.name] = field.value) + + // if there are no dirty fields, don't actually perform the save + const savePromise: Promise = !dirty ? Promise.resolve() : save(formValue) + + return savePromise + .then(() => { + const safeSuccessMessage: string = !!successMessage + ? successMessage as string + : `Your ${shortName || 'data'} has been saved.` + toast.success(safeSuccessMessage) + onSuccess?.() + }) + .catch(error => { + return Promise.reject(error.response?.data?.result?.content || error.message || error) + }) +} + +function handleFieldEvent(input: HTMLInputElement | HTMLTextAreaElement, inputs: ReadonlyArray, event: 'blur' | 'change', formValues?: T): void { + + // set the dirty and touched flags on the field + const originalValue: string | undefined = (formValues as any)?.[input.name] + + const inputDef: FormInputModel = getInputModel(inputs, input.name) + if (event === 'change') { + inputDef.dirty = input.value !== originalValue + } + inputDef.touched = true + + // set the def value + inputDef.value = input.value + + // now let's validate the field + const formElements: HTMLFormControlsCollection = (input.form as HTMLFormElement).elements + validateField(inputDef, formElements, event) + + // if the input doesn't have any dependent fields, we're done + if (!inputDef.dependentFields?.length) { + return + } + + inputDef.dependentFields + .forEach(dependentField => { + const dependentFieldDef: FormInputModel = getInputModel(inputs, dependentField) + validateField(dependentFieldDef, formElements, event) + }) +} + +function validateField(formInputDef: FormInputModel, formElements: HTMLFormControlsCollection, event: 'blur' | 'change' | 'submit'): void { + + // this is the error the field had before the event took place + const previousError: string | undefined = formInputDef.error + + formInputDef.validators + ?.forEach(validatorFunction => { + + // if the next error is the same as the previous error, then no need to do anything + const nextError: string | undefined = validatorFunction.validator(formInputDef.value, formElements, validatorFunction.dependentField) + + if (previousError === nextError) { + return + } + + // we only remove errors on change + if (event === 'change') { + if (!nextError) { + formInputDef.error = undefined + } + return + } + + // this is an on blur or submit event, + // so if there is no current error for this field, + // set it to the next error + if (!formInputDef.error) { + formInputDef.error = nextError + } + }) +} + +function validateForm(inputs: ReadonlyArray, formElements: HTMLFormControlsCollection, event: 'blur' | 'change' | 'submit'): boolean { + const errors: ReadonlyArray = inputs + .filter(formInputDef => { + formInputDef.dirty = formInputDef.dirty || event === 'submit' + validateField(formInputDef, formElements, event) + return !!formInputDef.error + }) + return !errors.length +} diff --git a/src/lib/form/form-functions/index.ts b/src-ts/lib/form/form-functions/index.ts similarity index 65% rename from src/lib/form/form-functions/index.ts rename to src-ts/lib/form/form-functions/index.ts index f0979b852..5edea6f67 100644 --- a/src/lib/form/form-functions/index.ts +++ b/src-ts/lib/form/form-functions/index.ts @@ -1,9 +1,9 @@ export { - ErrorMessage as FormErrorMessage, getInputElement as formGetInput, getInputModel as formGetInputModel, initializeValues as formInitializeValues, + onBlur as formOnBlur, onChange as formOnChange, - reset as formReset, - submitAsync as formSubmitAsync, + onReset as formOnReset, + onSubmitAsync as formOnSubmitAsync, } from './form.functions' diff --git a/src/lib/form/form-input.model.ts b/src-ts/lib/form/form-input.model.ts similarity index 56% rename from src/lib/form/form-input.model.ts rename to src-ts/lib/form/form-input.model.ts index 7944daf94..99afc4150 100644 --- a/src/lib/form/form-input.model.ts +++ b/src-ts/lib/form/form-input.model.ts @@ -1,7 +1,9 @@ +import { FormInputAutocompleteOption } from './form-inputs' import { ValidatorFn } from './validator-functions' export interface FormInputModel { - readonly dependentField?: string + readonly autocomplete?: FormInputAutocompleteOption + readonly dependentFields?: Array dirty?: boolean disabled?: boolean error?: string @@ -11,11 +13,10 @@ export interface FormInputModel { readonly name: string readonly notTabbable?: boolean readonly placeholder?: string - readonly preventAutocomplete?: boolean + readonly spellCheck?: boolean readonly title?: string touched?: boolean - readonly type: 'password' | 'text' | 'textarea' - readonly validateOnBlur?: ValidatorFn - readonly validateOnChange?: ValidatorFn + readonly type: 'password' | 'rating' | 'text' | 'textarea' + readonly validators?: ReadonlyArray value?: string } diff --git a/src/lib/form/form-inputs/FormInputs.module.scss b/src-ts/lib/form/form-inputs/FormInputs.module.scss similarity index 100% rename from src/lib/form/form-inputs/FormInputs.module.scss rename to src-ts/lib/form/form-inputs/FormInputs.module.scss diff --git a/src/lib/form/form-inputs/FormInputs.tsx b/src-ts/lib/form/form-inputs/FormInputs.tsx similarity index 73% rename from src/lib/form/form-inputs/FormInputs.tsx rename to src-ts/lib/form/form-inputs/FormInputs.tsx index f75961dac..a0f657e43 100644 --- a/src/lib/form/form-inputs/FormInputs.tsx +++ b/src-ts/lib/form/form-inputs/FormInputs.tsx @@ -1,21 +1,21 @@ -import { FocusEvent } from 'react' +import { ChangeEvent, FocusEvent } from 'react' import { FormDefinition } from '../form-definition.model' import { formGetInputModel } from '../form-functions' -import { InputText, InputTextarea } from './form-input' +import { InputRating, InputText, InputTextarea } from './form-input' import { FormInputRow } from './form-input-row' import styles from './FormInputs.module.scss' interface FormInputsProps { formDef: FormDefinition onBlur: (event: FocusEvent) => void - onFocus: (event: FocusEvent) => void + onChange: (event: ChangeEvent) => void } const FormInputs: (props: FormInputsProps) => JSX.Element = (props: FormInputsProps) => { - const { formDef, onBlur, onFocus }: FormInputsProps = props + const { formDef, onBlur, onChange }: FormInputsProps = props const formInputs: Array = formDef.inputs .map(input => formGetInputModel(formDef.inputs, input.name)) @@ -26,12 +26,23 @@ const FormInputs: (props: FormInputsProps) => JSX.Element = (props: FormInputsPr let inputElement: JSX.Element switch (inputModel.type) { + case 'rating': + inputElement = ( + + ) + break + case 'textarea': inputElement = ( @@ -43,7 +54,7 @@ const FormInputs: (props: FormInputsProps) => JSX.Element = (props: FormInputsPr JSX.Element = (props: FormInpu ) - const inputElement: JSX.Element =
{children}
- const inputRow: JSX.Element = !input.instructions - ? inputElement + ? ( +
+ {children} +
+ ) : (
-
+
{input.instructions}
- {inputElement} +
+ {children} +
) return ( -
+
{title} {inputRow}
diff --git a/src/lib/form/form-inputs/form-input-row/index.ts b/src-ts/lib/form/form-inputs/form-input-row/index.ts similarity index 100% rename from src/lib/form/form-inputs/form-input-row/index.ts rename to src-ts/lib/form/form-inputs/form-input-row/index.ts diff --git a/src-ts/lib/form/form-inputs/form-input/form-input-autcomplete-option.enum.ts b/src-ts/lib/form/form-inputs/form-input/form-input-autcomplete-option.enum.ts new file mode 100644 index 000000000..24401e6eb --- /dev/null +++ b/src-ts/lib/form/form-inputs/form-input/form-input-autcomplete-option.enum.ts @@ -0,0 +1,6 @@ +export enum FormInputAutocompleteOption { + current = 'current-password', // NOTE: chrome only; FF converts this to 'off' + default = 'on', + new = 'new-password', + off = 'off', +} diff --git a/src/lib/form/form-inputs/form-input/index.ts b/src-ts/lib/form/form-inputs/form-input/index.ts similarity index 56% rename from src/lib/form/form-inputs/form-input/index.ts rename to src-ts/lib/form/form-inputs/form-input/index.ts index 590fdf121..3f5dfa642 100644 --- a/src/lib/form/form-inputs/form-input/index.ts +++ b/src-ts/lib/form/form-inputs/form-input/index.ts @@ -1,3 +1,5 @@ -export { inputOptional } from './input-wrapper' +export * from './form-input-autcomplete-option.enum' +export * from './input-rating' export * from './input-text' export * from './input-textarea' +export { inputOptional } from './input-wrapper' diff --git a/src-ts/lib/form/form-inputs/form-input/input-rating/InputRating.module.scss b/src-ts/lib/form/form-inputs/form-input/input-rating/InputRating.module.scss new file mode 100644 index 000000000..d9e67b41a --- /dev/null +++ b/src-ts/lib/form/form-inputs/form-input/input-rating/InputRating.module.scss @@ -0,0 +1,24 @@ +@import '../../../../styles/includes'; + +.rating-input-wrapper { + &.rating-input-wrapper { + padding: 0; + border: 0 none; + } + > label { + margin-bottom: 0; + } +} + +.ratings-container { + display: flex; + justify-content: space-between; + + :global(.button) { + padding: 4px 4px 2px; + margin: -4px 0; + &:hover { + color: $orange-100; + } + } +} diff --git a/src-ts/lib/form/form-inputs/form-input/input-rating/InputRating.tsx b/src-ts/lib/form/form-inputs/form-input/input-rating/InputRating.tsx new file mode 100644 index 000000000..69fb94770 --- /dev/null +++ b/src-ts/lib/form/form-inputs/form-input/input-rating/InputRating.tsx @@ -0,0 +1,90 @@ +import classNames from 'classnames' +import { + ChangeEvent, + createRef, + Dispatch, + FC, + MouseEvent, + RefObject, + SetStateAction, + useEffect, + useState, +} from 'react' + +import { Button } from '../../../../button' +import '../../../../styles/index.scss' +import { IconSolid } from '../../../../svgs' +import { InputWrapper } from '../input-wrapper' + +import styles from './InputRating.module.scss' + +interface InputRatingProps { + readonly dirty?: boolean + readonly disabled?: boolean + readonly error?: string + readonly name: string + readonly onChange: (event: ChangeEvent) => void + readonly tabIndex: number + readonly value?: string | number +} + +const InputRating: FC = (props: InputRatingProps) => { + + const [rating, setRating]: [number | undefined, Dispatch>] = useState(!!props.value ? (+props.value / 2) : undefined) + + const inputRef: RefObject = createRef() + + const stars: Array = [] + // ratings are base 10, but we're only showing 5 stars, + // so only display buttons for 2, 4, 6, 8, and 10 + for (let index: number = 2; index <= 10; index++) { + const className: string = !!rating && rating >= index ? 'orange-100' : 'black-20' + const element: JSX.Element = ( +