diff --git a/.circleci/config.yml b/.circleci/config.yml index 26ede8fd0..d49a32f50 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -37,6 +37,12 @@ save_cache_settings: &save_cache_settings paths: - node_modules +running_yarn_tslint: &running_yarn_tslint + name: Running Yarn tslint + command: | + yarn add tslint -g + yarn lint + running_yarn_build: &running_yarn_build name: Running Yarn Build command: | @@ -58,6 +64,12 @@ build_configuration_fetch: &build_configuration_fetch ./awsconfiguration.sh $DEPLOY_ENV ./buildenv.sh -e $DEPLOY_ENV -b ${LOGICAL_ENV}-${APPNAME}-buildvar +lint_steps: &lint_steps + # Initialization. + - checkout + - setup_remote_docker + - run: *running_yarn_tslint + build_steps: &build_steps # Initialization. - checkout @@ -83,6 +95,22 @@ deploy_steps: &deploy_steps ./master_deploy.sh -d CFRONT -e $DEPLOY_ENV -c $ENABLE_CACHE jobs: + lint-dev: + <<: *defaults + environment: + DEPLOY_ENV: "DEV" + LOGICAL_ENV: "dev" + APPNAME: "platform-ui-mvp" + steps: *lint_steps + + lint-prod: + <<: *defaults + environment: + DEPLOY_ENV: "PROD" + LOGICAL_ENV: "prod" + APPNAME: "platform-ui-mvp" + steps: *lint_steps + build-dev: <<: *defaults environment: @@ -122,6 +150,20 @@ workflows: version: 2 build: jobs: + - lint-dev: + context : org-global + filters: + branches: + ignore: + - master + + - lint-prod: + context : org-global + filters: + branches: + only: + - master + - build-dev: context : org-global filters: diff --git a/README.md b/README.md index 44b167472..4951a9a68 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,30 @@ The Platform UI is the official Topcoder web app to host all modern user interfaces to be used by all users. -Beginning March, 2022 all future user interfaces at Topcoder will be implemented here. Pre-existing user interfaces will be ported to here over time until this is the only user interface any user sees when interacting with Topcoder. +All future user interfaces at Topcoder will be implemented here. +Pre-existing user interfaces will be ported to here over time until this is the only user interface any user sees when interacting with Topcoder. + +>**NOTE:** The information in this file describes our coding standards and best practices. All new code should follow these guidelines both when coding new features as well as porting old features. Please take the time to read through this file in detail. + +# Getting started with local development - [Local Environment Setup](#local-environment-setup) - [Deployments](#deployments) - [Yarn Commands](#yarn-commands) + +# Application structure + - [Folder Structure](#folder-structure) - [Adding a Tool or Util](#adding-a-tool-or-util) + +# Coding Practices - [Git](#git) - [Linting](#linting) - [Styling](#styling) - [Icons](#icons) +--- + ## Local Environment Setup ### Dependencies @@ -393,6 +405,23 @@ example.scss } } ``` + +Mobile UIs use xs, sm, and md breakpoints. Larger breakpoints are desktop UIs. + +For specifying mobile CSS, you can use @include ltemd: +``` +.exampleDesktopContent { + display: flex; + width: 100%; + flex-direction: column; + + @include ltemd { + flex-direction: row; + } +} +``` + + >**WARNING:** Do not add any breakpoints! ## Icons diff --git a/package.json b/package.json index afde5d6ec..1fa4fdc1f 100644 --- a/package.json +++ b/package.json @@ -6,21 +6,25 @@ "start": "sh start-ssl.sh", "start:bsouza": "sh start-ssl-bsouza.sh", "build": "yarn react-app-rewired build", - "lint": "tslint 'src-ts/**/*.{ts,tsx}'", - "lint:fix": "tslint 'src-ts/**/*.{ts,tsx}' --fix", + "lint": "tslint 'src-ts/**/*.{ts,tsx}' && eslint 'src*/**/*.{js,jsx,ts,tsx}'", + "lint:fix": "tslint 'src-ts/**/*.{ts,tsx}' --fix && eslint 'src*/**/*.{js,jsx,ts,tsx}' --fix", + "tslint": "tslint 'src-ts/**/*.{ts,tsx}'", + "tslint:fix": "tslint 'src-ts/**/*.{ts,tsx}' --fix", "eslint": "eslint 'src/**/*.{js,jsx}'", "eslint:fix": "eslint 'src/**/*.{js,jsx}' --fix", "test": "react-scripts test --watchAll", "test:no-watch": "react-scripts test --watchAll=false --passWithNoTests" }, "dependencies": { - "@datadog/browser-logs": "^4.5.0", + "@datadog/browser-logs": "^4.7.1", "@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", + "customize-cra": "^1.0.0", + "html2canvas": "^1.4.1", "lodash": "^4.17.21", "moment": "^2.29.3", "moment-timezone": "^0.5.34", @@ -28,6 +32,7 @@ "rc-checkbox": "^2.3.2", "react": "^17.0.2", "react-apexcharts": "^1.4.0", + "react-app-rewired": "^2.2.1", "react-dom": "^17.0.2", "react-elastic-carousel": "^0.11.5", "react-gtm-module": "^2.0.11", @@ -46,10 +51,9 @@ "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" + "tc-auth-lib": "topcoder-platform/tc-auth-lib#1.0.4", + "typescript": "^4.6.3", + "uuid": "^8.3.2" }, "devDependencies": { "@babel/core": "^7.7.5", @@ -67,14 +71,16 @@ "@types/axios": "^0.14.0", "@types/jest": "^27.0.1", "@types/lodash": "^4.14.182", - "@types/node": "^16.7.13", + "@types/node": "^17.0.24", "@types/reach__router": "^1.3.10", - "@types/react": "^17.0.20", - "@types/react-dom": "^17.0.9", + "@types/react": "^18.0.5", + "@types/react-dom": "^18.0.1", "@types/react-gtm-module": "^2.0.1", "@types/react-redux-toastr": "^7.6.2", "@types/react-router-dom": "^5.3.3", + "@types/segment-analytics": "^0.0.34", "@types/systemjs": "^6.1.0", + "@types/uuid": "^8.3.4", "autoprefixer": "^9.8.6", "babel-eslint": "^11.0.0-beta.2", "babel-jest": "^24.9.0", @@ -84,23 +90,22 @@ "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", + "husky": "^8.0.0", "identity-obj-proxy": "^3.0.0", "jest": "^25.2.7", "jest-cli": "^25.2.7", + "lint-staged": "^13.0.3", "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", "style-loader": "^2.0.0", "systemjs-webpack-interop": "^2.1.2", diff --git a/src-ts/App.tsx b/src-ts/App.tsx index 830c8d433..10e9900f5 100644 --- a/src-ts/App.tsx +++ b/src-ts/App.tsx @@ -2,12 +2,8 @@ import { FC, ReactElement, useContext } from 'react' import { Routes } from 'react-router-dom' import { toast, ToastContainer } from 'react-toastify' -import { EnvironmentConfig } from './config' import { Header } from './header' -import { analyticsInitialize, logInitialize, routeContext, RouteContextData } from './lib' - -analyticsInitialize(EnvironmentConfig) -logInitialize(EnvironmentConfig) +import { routeContext, RouteContextData } from './lib' const App: FC<{}> = () => { diff --git a/src-ts/config/constants.ts b/src-ts/config/constants.ts index f52749e6c..3d35f3335 100644 --- a/src-ts/config/constants.ts +++ b/src-ts/config/constants.ts @@ -1,4 +1,5 @@ export enum ToolTitle { + learn = 'Learn', settings = 'Account Settings', work = 'Work', } diff --git a/src-ts/config/environments/environment.default.config.ts b/src-ts/config/environments/environment.default.config.ts index b7bf62fa7..f1339ec3b 100644 --- a/src-ts/config/environments/environment.default.config.ts +++ b/src-ts/config/environments/environment.default.config.ts @@ -2,7 +2,13 @@ import { GlobalConfig } from '../../lib' import { AppHostEnvironment } from './app-host-environment.enum' +const COMMUNITY_WEBSITE: string = 'https://www.topcoder-dev.com' + export const EnvironmentConfigDefault: GlobalConfig = { + ANALYTICS: { + SEGMENT_KEY: undefined, + TAG_MANAGER_ID: undefined, + }, API: { FORUM_ACCESS_TOKEN: 'va.JApNvUOx3549h20I6tnl1kOQDc75NDIp.0jG3dA.EE3gZgV', FORUM_V2: 'https://vanilla.topcoder-dev.com/api/v2', @@ -10,12 +16,26 @@ export const EnvironmentConfigDefault: GlobalConfig = { V5: 'https://api.topcoder-dev.com/v5', }, ENV: AppHostEnvironment.default, + LEARN_SRC: 'https://fcc.topcoder-dev.com:4431', LOGGING: { PUBLIC_TOKEN: 'puba0825671e469d16f940c5a30dc738f11', SERVICE: 'platform-ui', }, REAUTH_OFFSET: 55, - TAG_MANAGER_ID: undefined, + // TODO: Move stripe creds to .env file + STRIPE: { + ADMIN_TOKEN: + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw', + API_KEY: 'pk_test_rfcS49MHRVUKomQ9JgSH7Xqz', + API_VERSION: '2020-08-27', + CUSTOMER_TOKEN: + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzMiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.jl6Lp_friVNwEP8nfsfmL-vrQFzOFp2IfM_HC7AwGcg', + }, + TOPCODER_URLS: { + CHALLENGES_PAGE: `${COMMUNITY_WEBSITE}/challenges`, + GIGS_PAGE: `${COMMUNITY_WEBSITE}/gigs`, + USER_PROFILE: `${COMMUNITY_WEBSITE}/members`, + }, URL: { ACCOUNTS_APP_CONNECTOR: 'https://accounts-auth0.topcoder-dev.com', }, diff --git a/src-ts/config/environments/environment.dev.config.ts b/src-ts/config/environments/environment.dev.config.ts index 5a3256772..2bebd3fdc 100644 --- a/src-ts/config/environments/environment.dev.config.ts +++ b/src-ts/config/environments/environment.dev.config.ts @@ -1,14 +1,25 @@ import { GlobalConfig } from '../../lib' -import { ToolTitle } from '../constants' import { AppHostEnvironment } from './app-host-environment.enum' import { EnvironmentConfigDefault } from './environment.default.config' export const EnvironmentConfigDev: GlobalConfig = { ...EnvironmentConfigDefault, - DISABLED_TOOLS: [ - ToolTitle.designLib, - ], + ANALYTICS: { + SEGMENT_KEY: EnvironmentConfigDefault.ANALYTICS.SEGMENT_KEY, + TAG_MANAGER_ID: 'GTM-MXXQHG8', + // TAG_MANAGER_ID: 'GTM-W7B537Z', + }, + DISABLED_TOOLS: [], ENV: AppHostEnvironment.dev, - TAG_MANAGER_ID: 'GTM-W7B537Z', + LEARN_SRC: 'https://freecodecamp.topcoder-dev.com', + // TODO: Move stripe creds to .env file + STRIPE: { + ADMIN_TOKEN: + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw', + API_KEY: 'pk_test_rfcS49MHRVUKomQ9JgSH7Xqz', + API_VERSION: '2020-08-27', + CUSTOMER_TOKEN: + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzMiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.jl6Lp_friVNwEP8nfsfmL-vrQFzOFp2IfM_HC7AwGcg', + }, } diff --git a/src-ts/config/environments/environment.prod.config.ts b/src-ts/config/environments/environment.prod.config.ts index 1bb9d32d8..493bfdad9 100644 --- a/src-ts/config/environments/environment.prod.config.ts +++ b/src-ts/config/environments/environment.prod.config.ts @@ -1,19 +1,39 @@ import { GlobalConfig } from '../../lib' -import { ToolTitle } from '../constants' import { AppHostEnvironment } from './app-host-environment.enum' import { EnvironmentConfigDefault } from './environment.default.config' +const COMMUNITY_WEBSITE: string = 'https://www.topcoder.com' + export const EnvironmentConfigProd: GlobalConfig = { ...EnvironmentConfigDefault, + ANALYTICS: { + SEGMENT_KEY: '8fCbi94o3ruUUGxRRGxWu194t6iVq9LH', + TAG_MANAGER_ID: 'GTM-MXXQHG8', + }, API: { + FORUM_ACCESS_TOKEN: EnvironmentConfigDefault.API.FORUM_ACCESS_TOKEN, FORUM_V2: 'https://vanilla.topcoder.com/api/v2', V3: 'https://api.topcoder.com/v3', V5: 'https://api.topcoder.com/v5', }, DISABLED_TOOLS: [ ], ENV: AppHostEnvironment.prod, - TAG_MANAGER_ID: 'GTM-MXXQHG8', + LEARN_SRC: 'https://fcc.topcoder.com:4431', + // TODO: Move stripe creds to .env file + STRIPE: { + ADMIN_TOKEN: + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIiwiYWRtaW5pc3RyYXRvciJdLCJpc3MiOiJodHRwczovL2FwaS50b3Bjb2Rlci1kZXYuY29tIiwiaGFuZGxlIjoidGVzdDEiLCJleHAiOjI1NjMwNzY2ODksInVzZXJJZCI6IjQwMDUxMzMzIiwiaWF0IjoxNDYzMDc2MDg5LCJlbWFpbCI6InRlc3RAdG9wY29kZXIuY29tIiwianRpIjoiYjMzYjc3Y2QtYjUyZS00MGZlLTgzN2UtYmViOGUwYWU2YTRhIn0.wKWUe0-SaiFVN-VR_-GwgFlvWaDkSbc8H55ktb9LAVw', + API_KEY: 'pk_live_m3bCBVSfkfMOEp3unZFRsHXi', + API_VERSION: '2020-08-27', + CUSTOMER_TOKEN: + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJUb3Bjb2RlciBVc2VyIl0sImlzcyI6Imh0dHBzOi8vYXBpLnRvcGNvZGVyLWRldi5jb20iLCJoYW5kbGUiOiJ0ZXN0MSIsImV4cCI6MjU2MzA3NjY4OSwidXNlcklkIjoiNDAwNTEzMzMiLCJpYXQiOjE0NjMwNzYwODksImVtYWlsIjoidGVzdEB0b3Bjb2Rlci5jb20iLCJqdGkiOiJiMzNiNzdjZC1iNTJlLTQwZmUtODM3ZS1iZWI4ZTBhZTZhNGEifQ.jl6Lp_friVNwEP8nfsfmL-vrQFzOFp2IfM_HC7AwGcg', + }, + TOPCODER_URLS: { + CHALLENGES_PAGE: `${COMMUNITY_WEBSITE}/challenges`, + GIGS_PAGE: `${COMMUNITY_WEBSITE}/gigs`, + USER_PROFILE: `${COMMUNITY_WEBSITE}/members`, + }, URL: { ACCOUNTS_APP_CONNECTOR: 'https://accounts-auth0.topcoder.com', }, diff --git a/src-ts/declarations.d.ts b/src-ts/declarations.d.ts index 81c5ad039..fa09433a4 100644 --- a/src-ts/declarations.d.ts +++ b/src-ts/declarations.d.ts @@ -3,6 +3,8 @@ declare module '*.html' { export = htmlFile } +declare module '*.pdf' + declare module '*.scss' { const scssFile: { [style: string]: any } export = scssFile diff --git a/src-ts/header/logo/Logo.tsx b/src-ts/header/logo/Logo.tsx index db850207a..2d7744c50 100644 --- a/src-ts/header/logo/Logo.tsx +++ b/src-ts/header/logo/Logo.tsx @@ -1,26 +1,28 @@ import { FC, useContext } from 'react' import { Link, useLocation } from 'react-router-dom' -import { LogoIcon, routeContext, RouteContextData } 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) + const routeContextData: RouteContextData = useContext(routeContext) // the logo should be a link to the home page for all pages except the home page - const isLink: boolean = !isRootRoute(useLocation().pathname) + const isLink: boolean = !routeContextData.isRootRoute(useLocation().pathname) + const rootRoute: string = routeContextData.rootLoggedInRoute || '' return (
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 index 4b26c7a48..ea6454925 100644 --- 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 @@ -2,7 +2,7 @@ import classNames from 'classnames' import { FC, useContext } from 'react' import { Link, useLocation } from 'react-router-dom' -import { IconOutline, PlatformRoute, routeContext, RouteContextData } from '../../../../lib' +import { IconOutline, PlatformRoute, routeContext, RouteContextData, routeIsActiveTool } from '../../../../lib' import styles from './ToolSelectorNarrow.module.scss' @@ -16,15 +16,14 @@ const ToolSelectorNarrow: FC = (props: ToolSelectorNarr 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, toolRoute) + const isActive: boolean = routeIsActiveTool(useLocation().pathname, toolRoute) const activeIndicaterClass: string = `${baseClass}-${isActive ? '' : 'in'}active` - const hasChildren: boolean = !!toolRoute.children.some(child => !!child.route && !isParamRoute(child.route)) + const hasChildren: boolean = !!toolRoute.children?.some(child => !!child.route && !isParamRoute(child.route)) return (
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 index 489578025..e30a3c5ba 100644 --- 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 @@ -6,6 +6,7 @@ import { PlatformRoute, routeContext, RouteContextData, + routeIsActiveTool, } from '../../../../lib' import '../../../../lib/styles/index.scss' @@ -19,23 +20,22 @@ const ToolSelectorWide: FC = (props: ToolSelectorWideProp 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, toolRoute) - - const activeIndicatorClass: string = `tool-selector-wide-${isActive ? '' : 'in'}active` + const baseClass: string = 'tool-selector-wide' + const isActive: boolean = routeIsActiveTool(activePath, toolRoute) + const activeIndicatorClass: string = `${baseClass}-${isActive ? '' : 'in'}active` // the tool link should be usable for all active routes except the home page const isLink: boolean = isActive && !isRootRoute(activePath) return (
diff --git a/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/ProfileLoggedIn.tsx b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/ProfileLoggedIn.tsx index 26ccab110..e6285eca5 100644 --- a/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/ProfileLoggedIn.tsx +++ b/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-logged-in/ProfileLoggedIn.tsx @@ -20,11 +20,6 @@ const ProfileLoggedIn: FC = (props: ProfileLoggedInProps) const { profile }: ProfileContextData = useContext(profileContext) - if (!profile) { - logInfo('tried to render the logged in profile w/out a profile') - return <> - } - const triggerRef: MutableRefObject = useRef(undefined) const [profilePanelOpen, setProfilePanelOpen]: [boolean, Dispatch>] = useState(false) @@ -34,6 +29,11 @@ const ProfileLoggedIn: FC = (props: ProfileLoggedInProps) useClickOutside(triggerRef.current, () => setProfilePanelOpen(false)) + if (!profile) { + logInfo('tried to render the logged in profile w/out a profile') + return <> + } + return ( <>
= (props: ProfilePanelProps) => { const { profile }: ProfileContextData = useContext(profileContext) - const { - getPath, - rootLoggedOutRoute, - }: RouteContextData = useContext(routeContext) + const { getPath }: RouteContextData = useContext(routeContext) const navigate: NavigateFunction = useNavigate() @@ -61,7 +58,7 @@ const ProfilePanel: FC = (props: ProfilePanelProps) => {
diff --git a/src-ts/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 deleted file mode 100644 index 47fb16585..000000000 --- a/src-ts/header/utility-selectors/UtilitySelector/ProfileSelector/profile-not-logged-in/ProfileNotLoggedIn.module.scss +++ /dev/null @@ -1,12 +0,0 @@ -@import '../../../../../lib/styles/includes'; - -@include ltemd { - .login, - .signup { - display: none; - - &:last-of-type { - display: inline; - } - } -} diff --git a/src-ts/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 index 255ccba68..435f0e0f1 100644 --- a/src-ts/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,31 +1,40 @@ import { FC, useContext } from 'react' +import { Location, useLocation } from 'react-router-dom' -import { authUrlLogin, authUrlSignup, Button, routeContext, RouteContextData } from '../../../../../lib' +import { + authUrlLogin, + Button, + routeContext, + RouteContextData, +} from '../../../../../lib' import '../../../../../lib/styles/index.scss' -import styles from './ProfileNotLoggedIn.module.scss' - const ProfileNotLoggedIn: FC<{}> = () => { - const { rootLoggedInRoute }: RouteContextData = useContext(routeContext) + const routeData: RouteContextData = useContext(routeContext) + const location: Location = useLocation() + + function signUp(): void { + const signupUrl: string = routeData.getSignupUrl(location.pathname, routeData.toolsRoutes) + window.location.href = signupUrl + } return ( <> + ) + } + + // this is a different element, so construct and render it + const ButtonElement: keyof JSX.IntrinsicElements = props.elementType return ( = (props: ButtonProps) => { onClick={clickHandler} tabIndex={props.tabIndex} title={props.title} - type={props.type || 'button'} > {content} @@ -82,7 +105,8 @@ function getButtonClasses(props: ButtonProps): string { props.className, props.buttonStyle || 'primary', `button-${props.size || 'md'}`, - !!props.disable ? 'disabled' : undefined + !!props.disable ? 'disabled' : undefined, + props.hidden ? 'hidden' : undefined, ) return classes } 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 index 358990d84..c41d6dff9 100644 --- a/src-ts/lib/contact-support-form/contact-support-form.config.ts +++ b/src-ts/lib/contact-support-form/contact-support-form.config.ts @@ -8,59 +8,67 @@ export enum ContactSupportFormField { } export const contactSupportFormDef: FormDefinition = { - buttons: [ + buttons: { + primaryGroup: [ + { + buttonStyle: 'secondary', + isSubmit: true, + label: 'Submit', + size: 'lg', + type: 'submit', + }, + ], + }, + groups: [ { - buttonStyle: 'secondary', - isSave: true, - label: 'Submit', - size: 'lg', - type: 'submit', - }, - ], - inputs: [ - { - label: 'First Name', - name: ContactSupportFormField.first, - type: 'text', - validators: [ + inputs: [ { - validator: validatorRequired, + label: 'First Name', + name: ContactSupportFormField.first, + type: 'text', + validators: [ + { + validator: validatorRequired, + }, + ], }, - ], - }, - { - label: 'Last Name', - name: ContactSupportFormField.last, - 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, + label: 'Email', + name: ContactSupportFormField.email, + type: 'text', + validators: [ + { + validator: validatorEmail, + }, + { + validator: validatorRequired, + }, + ], }, { - validator: validatorRequired, - }, - ], - }, - { - label: 'How can we help you?', - name: ContactSupportFormField.question, - type: 'textarea', - validators: [ - { - validator: validatorRequired, + label: 'How can we help you?', + name: ContactSupportFormField.question, + type: 'textarea', + validators: [ + { + validator: validatorRequired, + }, + ], }, ], }, ], successMessage: 'Your request has been submitted.', } + +export const contactSupportPath: string = '/support' diff --git a/src-ts/lib/contact-support-form/index.ts b/src-ts/lib/contact-support-form/index.ts index 47c0aff74..162bbddb2 100644 --- a/src-ts/lib/contact-support-form/index.ts +++ b/src-ts/lib/contact-support-form/index.ts @@ -1,2 +1,2 @@ export { default as ContactSupportForm } from './ContactSupportForm' -export { contactSupportFormDef } from './contact-support-form.config' +export { contactSupportFormDef, contactSupportPath } from './contact-support-form.config' diff --git a/src-ts/lib/form/Form.module.scss b/src-ts/lib/form/Form.module.scss index 2fc69ee93..3a7301432 100644 --- a/src-ts/lib/form/Form.module.scss +++ b/src-ts/lib/form/Form.module.scss @@ -19,19 +19,38 @@ 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; + + .right-container { + display: flex; + flex: 1; + justify-content: flex-end; + + button { + margin-right: 16px; + + &:last-child { + margin-right: 0; + } + } + } + + .left-container { + display: flex; + flex: 1; + justify-content: flex-start; + + button { + margin-right: 16px; + + &:last-child { + margin-right: 0; + } + } + } } .form-error { @@ -52,4 +71,4 @@ margin-right: 0; } } -} +} \ No newline at end of file diff --git a/src-ts/lib/form/Form.tsx b/src-ts/lib/form/Form.tsx index e618e63ae..c1aa827d1 100644 --- a/src-ts/lib/form/Form.tsx +++ b/src-ts/lib/form/Form.tsx @@ -15,21 +15,24 @@ import { Button } from '../button' import '../styles/index.scss' import { IconOutline } from '../svgs' -import { FormDefinition } from './form-definition.model' +import { FormAction, FormButton, FormDefinition, FormInputModel } from '.' import { + formGetInputFields, formInitializeValues, formOnBlur, formOnChange, formOnReset, formOnSubmitAsync, } from './form-functions' -import { FormInputModel } from './form-input.model' -import { FormInputs } from './form-inputs' +import { validateForm } from './form-functions/form.functions' +import { FormGroups } from './form-groups' import styles from './Form.module.scss' interface FormProps { + readonly action?: FormAction // only type submit will perform validation readonly formDef: FormDefinition readonly formValues?: ValueType + readonly onChange?: (inputs: ReadonlyArray) => void, readonly onSuccess?: () => void readonly requestGenerator: (inputs: ReadonlyArray) => RequestType readonly save: (value: RequestType) => Promise @@ -50,40 +53,86 @@ const Form: (props: FormProps, Dispatch>>] = useState>(createRef()) + // This will hold all the inputs + const [inputs, setInputs]: [Array, Dispatch>>] = useState>(formGetInputFields(formDef.groups || [])) + const [isFormInvalid, setFormInvalid]: [boolean, Dispatch] = useState(inputs.filter(item => !!item.error).length > 0) + + useEffect(() => { + if (!formRef.current?.elements) { + return + } + + validateForm(formRef.current?.elements, 'initial', inputs) + checkIfFormIsValid(inputs) + }, [ + formRef, + inputs, + ]) + + useEffect(() => { + if (!formRef.current?.elements) { + return + } + + // so we repeat the validation when formValues changes, to support the parent component's async data loading + validateForm(formRef.current?.elements, 'change', inputs) + checkIfFormIsValid(inputs) + }, [ + props.formValues, + formRef, + inputs, + ]) + + function checkIfFormIsValid(formInputFields: Array): void { + setFormInvalid(formInputFields.filter(item => !!item.error).length > 0) + } + function onBlur(event: FocusEvent): void { - formOnBlur(event, formDef.inputs, props.formValues) + formOnBlur(event, inputs, props.formValues) setFormDef({ ...formDef }) + const formInputFields: Array = formGetInputFields(formDef.groups || []) + setInputs(formInputFields) + checkIfFormIsValid(formInputFields) } function onChange(event: ChangeEvent): void { - formOnChange(event, formDef.inputs, props.formValues) + formOnChange(event, inputs, props.formValues) + const formInputFields: Array = formGetInputFields(formDef.groups || []) + setInputs(formInputFields) setFormDef({ ...formDef }) + checkIfFormIsValid(formInputFields) + if (props.onChange) { + props.onChange(inputs) + } } function onReset(): void { - formOnReset(formDef.inputs, props.formValues) + formOnReset(inputs, props.formValues) setFormDef({ ...formDef }) + setInputs(formGetInputFields(formDef.groups || [])) setFormKey(Date.now()) } async function onSubmitAsync(event: FormEvent): Promise { - const values: RequestType = props.requestGenerator(formDef.inputs) - formOnSubmitAsync(event, formDef, values, props.save, props.onSuccess) + const values: RequestType = props.requestGenerator(inputs) + formOnSubmitAsync(props.action || 'submit', event, formDef, values, props.save, props.onSuccess) .then(() => { setFormKey(Date.now()) - formOnReset(formDef.inputs, props.formValues) + formOnReset(inputs, props.formValues) setFormDef({ ...formDef }) + setInputs(formGetInputFields(formDef.groups || [])) }) .catch((error: string | undefined) => { setFormError(error) setFormDef({ ...formDef }) + setInputs(formGetInputFields(formDef.groups || [])) }) } - formInitializeValues(formDef.inputs, props.formValues) + formInitializeValues(inputs, props.formValues) - const buttons: Array = formDef.buttons - .map((button, index) => { + const createButtonGroup: (groups: ReadonlyArray, isPrimaryGroup: boolean) => Array = (groups, isPrimaryGroup) => { + return groups.map((button, index) => { // if this is a reset button, set its onclick to reset if (!!button.isReset) { button = { @@ -91,14 +140,21 @@ const Form: (props: FormProps ) }) + } + + const secondaryGroupButtons: Array = createButtonGroup(formDef.buttons.secondaryGroup || [], false) + + const primaryGroupButtons: Array = createButtonGroup(formDef.buttons.primaryGroup, true) // set the max width of the form error so that it doesn't push the width of the form wider const errorsRef: RefObject = createRef() @@ -110,7 +166,10 @@ const Form: (props: FormProps(props: FormProps )} - (props: FormProps )}
- {buttons} +
+ {secondaryGroupButtons} +
+
+ {primaryGroupButtons} +
diff --git a/src-ts/lib/form/form-button.model.ts b/src-ts/lib/form/form-button.model.ts index 49918ca7e..fb87d95bf 100644 --- a/src-ts/lib/form/form-button.model.ts +++ b/src-ts/lib/form/form-button.model.ts @@ -1,12 +1,16 @@ +import { FC, SVGProps } from 'react' + import { ButtonSize, ButtonStyle, ButtonType } from '../button' export interface FormButton { readonly buttonStyle?: ButtonStyle + hidden?: boolean, + readonly icon?: FC> readonly isReset?: boolean - readonly isSave?: boolean - readonly label: string + readonly isSubmit?: boolean + readonly label?: string readonly notTabble?: boolean - readonly onClick?: (event?: any) => void + onClick?: (event?: any) => void readonly route?: string readonly size?: ButtonSize readonly type?: ButtonType diff --git a/src-ts/lib/form/form-definition.model.ts b/src-ts/lib/form/form-definition.model.ts index a331a33be..fef7d8c58 100644 --- a/src-ts/lib/form/form-definition.model.ts +++ b/src-ts/lib/form/form-definition.model.ts @@ -1,9 +1,15 @@ -import { FormButton } from './form-button.model' -import { FormInputModel } from './form-input.model' +import { FormButton, FormGroup } from '.' + +export type FormAction = 'save' | 'submit' | undefined + +export interface FormButtons { + primaryGroup: ReadonlyArray + secondaryGroup?: ReadonlyArray +} export interface FormDefinition { - readonly buttons: ReadonlyArray - readonly inputs: ReadonlyArray + readonly buttons: FormButtons + readonly groups?: Array readonly shortName?: string readonly subtitle?: string readonly successMessage?: string diff --git a/src-ts/lib/form/form-functions/form.functions.ts b/src-ts/lib/form/form-functions/form.functions.ts index 32e38bf4a..5f5e72246 100644 --- a/src-ts/lib/form/form-functions/form.functions.ts +++ b/src-ts/lib/form/form-functions/form.functions.ts @@ -1,13 +1,22 @@ import { ChangeEvent, FormEvent } from 'react' import { toast } from 'react-toastify' -import { FormDefinition } from '../form-definition.model' +import { FormAction, FormDefinition } from '../form-definition.model' +import { FormGroup } from '../form-group.model' import { FormInputModel } from '../form-input.model' export function getInputElement(formElements: HTMLFormControlsCollection, fieldName: string): HTMLInputElement { return formElements.namedItem(fieldName) as HTMLInputElement } +export function getFormInputFields(groups: ReadonlyArray): Array { + const formInputs: Array = groups.reduce((current: Array, previous: FormGroup) => { + const formGroupInputs: ReadonlyArray = previous.inputs || [] + return [...current, ...formGroupInputs] + }, []) as Array + return formInputs +} + export function getInputModel(inputs: ReadonlyArray, fieldName: string): FormInputModel { const formField: FormInputModel | undefined = inputs.find(input => input.name === fieldName) @@ -20,7 +29,7 @@ export function getInputModel(inputs: ReadonlyArray, fieldName: return formField } -export function initializeValues(inputs: ReadonlyArray, formValues?: T): void { +export function initializeValues(inputs: Array, formValues?: T): void { inputs .filter(input => !input.dirty && !input.touched) .forEach(input => { @@ -39,16 +48,17 @@ export function onChange(event: ChangeEvent, formValue?: any): void { - inputs - .forEach(inputDef => { - inputDef.dirty = false - inputDef.touched = false - inputDef.error = undefined - inputDef.value = formValue?.[inputDef.name] - }) + inputs?.forEach(inputDef => { + const typeCastedInput: FormInputModel = inputDef as FormInputModel + typeCastedInput.dirty = false + typeCastedInput.touched = false + typeCastedInput.error = undefined + typeCastedInput.value = formValue?.[inputDef.name] + }) } export async function onSubmitAsync( + action: FormAction, event: FormEvent, formDef: FormDefinition, formValue: T, @@ -58,23 +68,29 @@ export async function onSubmitAsync( event.preventDefault() - const { inputs, shortName, successMessage }: FormDefinition = formDef + const { groups, shortName, successMessage }: FormDefinition = formDef + const inputs: Array = getFormInputFields(groups || []) // get the dirty fields before we validate b/c validation marks them dirty on submit - const dirty: FormInputModel | undefined = inputs.find(fieldDef => !!fieldDef.dirty) + 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() + if (action === 'submit') { + const isValid: boolean = validateForm(formValues, action, inputs) + if (!isValid) { + return Promise.reject() + } } // set the properties for the updated T value - inputs.forEach(field => (formValue as any)[field.name] = field.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) @@ -98,6 +114,7 @@ function handleFieldEvent(input: HTMLInputElement | HTMLTextAreaElement, inpu const originalValue: string | undefined = (formValues as any)?.[input.name] const inputDef: FormInputModel = getInputModel(inputs, input.name) + if (event === 'change') { inputDef.dirty = input.value !== originalValue } @@ -122,7 +139,7 @@ function handleFieldEvent(input: HTMLInputElement | HTMLTextAreaElement, inpu }) } -function validateField(formInputDef: FormInputModel, formElements: HTMLFormControlsCollection, event: 'blur' | 'change' | 'submit'): void { +function validateField(formInputDef: FormInputModel, formElements: HTMLFormControlsCollection, event: 'blur' | 'change' | 'submit' | 'initial'): void { // this is the error the field had before the event took place const previousError: string | undefined = formInputDef.error @@ -154,12 +171,11 @@ function validateField(formInputDef: FormInputModel, formElements: HTMLFormContr }) } -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 - }) +export function validateForm(formElements: HTMLFormControlsCollection, event: 'blur' | 'change' | 'submit' | 'initial', inputs: ReadonlyArray): 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-ts/lib/form/form-functions/index.ts b/src-ts/lib/form/form-functions/index.ts index 5edea6f67..5928e9090 100644 --- a/src-ts/lib/form/form-functions/index.ts +++ b/src-ts/lib/form/form-functions/index.ts @@ -6,4 +6,5 @@ export { onChange as formOnChange, onReset as formOnReset, onSubmitAsync as formOnSubmitAsync, + getFormInputFields as formGetInputFields, } from './form.functions' diff --git a/src-ts/lib/form/form-group.model.ts b/src-ts/lib/form/form-group.model.ts new file mode 100644 index 000000000..7fe97df9f --- /dev/null +++ b/src-ts/lib/form/form-group.model.ts @@ -0,0 +1,8 @@ +import { FormInputModel } from './form-input.model' + +export interface FormGroup { + readonly element?: JSX.Element + inputs?: ReadonlyArray + readonly instructions?: string + readonly title?: string +} diff --git a/src-ts/lib/form/form-inputs/FormInputs.module.scss b/src-ts/lib/form/form-groups/FormGroups.module.scss similarity index 84% rename from src-ts/lib/form/form-inputs/FormInputs.module.scss rename to src-ts/lib/form/form-groups/FormGroups.module.scss index c859f6876..6c3e7a4b0 100644 --- a/src-ts/lib/form/form-inputs/FormInputs.module.scss +++ b/src-ts/lib/form/form-groups/FormGroups.module.scss @@ -1,4 +1,4 @@ -.form-inputs { +.form-groups { display: grid; grid-template-columns: 1fr; justify-content: center; diff --git a/src-ts/lib/form/form-groups/FormGroups.tsx b/src-ts/lib/form/form-groups/FormGroups.tsx new file mode 100644 index 000000000..2562d0146 --- /dev/null +++ b/src-ts/lib/form/form-groups/FormGroups.tsx @@ -0,0 +1,114 @@ +import { ChangeEvent, FocusEvent } from 'react' + +import { FormDefinition } from '../form-definition.model' +import { FormGroup } from '../form-group.model' +import { FormInputModel } from '../form-input.model' + +import { FormCardSet } from './form-card-set' +import FormGroupItem from './form-group-item/FormGroupItem' +import { InputRating, InputText, InputTextarea } from './form-input' +import { FormInputRow } from './form-input-row' +import { InputTextTypes } from './form-input/input-text/InputText' +import FormRadio from './form-radio' +import styles from './FormGroups.module.scss' + +interface FormGroupsProps { + formDef: FormDefinition + inputs: Array + onBlur: (event: FocusEvent) => void + onChange: (event: ChangeEvent) => void +} + +const FormGroups: (props: FormGroupsProps) => JSX.Element = (props: FormGroupsProps) => { + + const { formDef, onBlur, onChange }: FormGroupsProps = props + + const getTabIndex: (input: FormInputModel, index: number) => number = (input, index) => { + const tabIndex: number = input.notTabbable ? -1 : index + 1 + (formDef.tabIndexStart || 0) + return tabIndex + } + + const renderInputField: (input: FormInputModel, index: number) => JSX.Element | undefined = (input, index) => { + const tabIndex: number = getTabIndex(input, index) + + let inputElement: JSX.Element + switch (input.type) { + + case 'rating': + inputElement = ( + + ) + break + + case 'textarea': + inputElement = ( + + ) + break + case 'checkbox': + case 'radio': + inputElement = ( + + ) + break + case 'card-set': + inputElement = ( + + ) + break + default: + inputElement = ( + + ) + break + } + + return ( + + {inputElement} + + ) + } + + const formGroups: Array = formDef?.groups?.map((element: FormGroup, index: number) => { + return + }) || [] + + return ( +
+ {formGroups} +
+ ) +} + +export default FormGroups diff --git a/src-ts/lib/form/form-groups/form-card-set/FormCardSet.module.scss b/src-ts/lib/form/form-groups/form-card-set/FormCardSet.module.scss new file mode 100644 index 000000000..0e938f55c --- /dev/null +++ b/src-ts/lib/form/form-groups/form-card-set/FormCardSet.module.scss @@ -0,0 +1,73 @@ +@import '../../../styles/includes'; + +.form-card-set { + display: flex; + flex: 1; + margin-bottom: $pad-xxl; + @include ltemd { + flex-direction: column; + margin-bottom: $pad-lg; + } + + input[type=radio] { + opacity: 0; + } + + .card { + flex: 1 1 0; + margin-right: $pad-xl; + border: solid 1px $black-80; + border-radius: $pad-xs; + padding: $pad-lg; + cursor: pointer; + + &.selected { + background-color: $turq-160; + border: 1px solid $turq-160; + color: $tc-white; + svg { + path { + color: $tc-white; + } + } + } + + @include ltemd { + margin-right: 0; + margin-bottom: $pad-lg; + &:last-child { + margin-bottom: 0; + } + } + &:last-child { + margin-right: 0; + } + + .card-header { + text-align: center; + } + + .card-section { + margin: $pad-lg 0; + + .card-row { + display: flex; + + .card-row-col { + align-content: center; + align-items: center; + width: 50%; + flex-wrap: wrap; + flex-grow: 1; + } + } + } + } + + svg.card-row-icon { + height: 14px; + width: 14px; + display: inline; + margin-right: 6px; + } +} diff --git a/src-ts/lib/form/form-groups/form-card-set/FormCardSet.tsx b/src-ts/lib/form/form-groups/form-card-set/FormCardSet.tsx new file mode 100644 index 000000000..665af51a1 --- /dev/null +++ b/src-ts/lib/form/form-groups/form-card-set/FormCardSet.tsx @@ -0,0 +1,70 @@ +import cn from 'classnames' +import React, { FocusEvent, SVGProps } from 'react' + +import { IconOutline, textFormatMoneyLocaleString } from '../../../../lib' +import { FormInputModel } from '../../form-input.model' + +import styles from './FormCardSet.module.scss' + +interface FormCardSetProps extends FormInputModel { + readonly onChange: (event: FocusEvent) => void +} + +const FormCardSet: React.FC = ({ name, cards, onChange, value }: FormCardSetProps) => { + + const iconFromName: (icon: string) => JSX.Element = (icon: string) => { + if (!icon) { + return <> + } + + const iconName: string = `${icon.split('-').map((chunk: string) => chunk.charAt(0).toUpperCase() + chunk.slice(1)).join('')}Icon` + const IconComponent: React.FC> = IconOutline[iconName as keyof typeof IconOutline] + return + } + + return ( +
+ { + cards?.map((card, index: number) => { + const formattedPrice: string | undefined = textFormatMoneyLocaleString(card.price) + const selected: boolean = value === card.id + + return ( + + ) + }) + } +
+ ) +} + +export default FormCardSet diff --git a/src-ts/lib/form/form-groups/form-card-set/index.ts b/src-ts/lib/form/form-groups/form-card-set/index.ts new file mode 100644 index 000000000..22dfea6c1 --- /dev/null +++ b/src-ts/lib/form/form-groups/form-card-set/index.ts @@ -0,0 +1 @@ +export { default as FormCardSet } from './FormCardSet' diff --git a/src-ts/lib/form/form-groups/form-group-item/FormGroupItem.module.scss b/src-ts/lib/form/form-groups/form-group-item/FormGroupItem.module.scss new file mode 100644 index 000000000..96d349533 --- /dev/null +++ b/src-ts/lib/form/form-groups/form-group-item/FormGroupItem.module.scss @@ -0,0 +1,57 @@ +@use '../../../styles/typography'; +@import "../../../styles/includes"; + +.form-group-item { + display: flex; + + &.single-field { + border-bottom: none; + padding: 0; + + .right { + margin-top: 0; + } + } + + @include ltemd { + flex-direction: column; + padding: $pad-xxl 0 $pad-sm 0; + } + + &:last-child { + border-bottom: none; + padding-bottom: 0; + } + + .left { + flex: 1; + + .title { + text-transform: uppercase; + } + + .group-item-instructions { + @extend .body-small; + @include font-roboto; + margin-top: $pad-xxl; + } + } + + .right { + flex: 1; + margin-top: 46px; + + @include ltemd { + margin-top: $pad-xxl; + } + } + + &.full-width-container { + display: inline; + + } + + .full-width-items { + margin-top: $pad-xl; + } +} diff --git a/src-ts/lib/form/form-groups/form-group-item/FormGroupItem.tsx b/src-ts/lib/form/form-groups/form-group-item/FormGroupItem.tsx new file mode 100644 index 000000000..41a568ec9 --- /dev/null +++ b/src-ts/lib/form/form-groups/form-group-item/FormGroupItem.tsx @@ -0,0 +1,83 @@ +import cn from 'classnames' +import React from 'react' + +import { PageDivider } from '../../../page-divider' +import { FormGroup } from '../../form-group.model' +import { FormInputModel } from '../../form-input.model' + +import styles from './FormGroupItem.module.scss' + +interface FormGroupItemProps { + group: FormGroup + renderFormInput: (input: FormInputModel, index: number) => JSX.Element | undefined + totalGroupCount: number +} + +interface ItemRowProps { + element?: JSX.Element, + formInputs: Array, + hasMultipleGroups: boolean, + instructions?: string | undefined, + isMultiFieldGroup: boolean, + title?: string, +} + +const TwoColumnItem: React.FC = ({ element, formInputs, hasMultipleGroups, instructions, isMultiFieldGroup, title }: ItemRowProps) => { + return ( + <> +
+ { + isMultiFieldGroup && ( +
+

+ {title} +

+
+
+ ) + } + {element} +
+ {formInputs} +
+
+ + + ) +} + +const SingleColumnItem: React.FC = ({ formInputs, hasMultipleGroups, instructions, isMultiFieldGroup, title }: ItemRowProps) => { + return ( + <> +
+ { + isMultiFieldGroup && ( + <> +

+ {title} +

+
+ + ) + } +
{formInputs}
+
+ + + ) +} + +const FormGroupItem: React.FC = ({ group, renderFormInput, totalGroupCount }: FormGroupItemProps) => { + const { instructions, title, inputs, element }: FormGroup = group + + const formInputs: Array = inputs?.map((field: FormInputModel, index: number) => renderFormInput(field as FormInputModel, index)) || [] + const hasMultipleGroups: boolean = totalGroupCount > 1 + const isMultiFieldGroup: boolean = !!(title || instructions) + const isCardSet: boolean = !!(inputs && inputs.every(input => typeof input.cards !== 'undefined')) + + return isCardSet ? + : + +} + +export default FormGroupItem diff --git a/src-ts/lib/form/form-inputs/form-input-row/FormInputRow.module.scss b/src-ts/lib/form/form-groups/form-input-row/FormInputRow.module.scss similarity index 100% rename from src-ts/lib/form/form-inputs/form-input-row/FormInputRow.module.scss rename to src-ts/lib/form/form-groups/form-input-row/FormInputRow.module.scss diff --git a/src-ts/lib/form/form-inputs/form-input-row/FormInputRow.tsx b/src-ts/lib/form/form-groups/form-input-row/FormInputRow.tsx similarity index 100% rename from src-ts/lib/form/form-inputs/form-input-row/FormInputRow.tsx rename to src-ts/lib/form/form-groups/form-input-row/FormInputRow.tsx diff --git a/src-ts/lib/form/form-inputs/form-input-row/index.ts b/src-ts/lib/form/form-groups/form-input-row/index.ts similarity index 100% rename from src-ts/lib/form/form-inputs/form-input-row/index.ts rename to src-ts/lib/form/form-groups/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-groups/form-input/form-input-autcomplete-option.enum.ts similarity index 100% rename from src-ts/lib/form/form-inputs/form-input/form-input-autcomplete-option.enum.ts rename to src-ts/lib/form/form-groups/form-input/form-input-autcomplete-option.enum.ts diff --git a/src-ts/lib/form/form-inputs/form-input/index.ts b/src-ts/lib/form/form-groups/form-input/index.ts similarity index 100% rename from src-ts/lib/form/form-inputs/form-input/index.ts rename to src-ts/lib/form/form-groups/form-input/index.ts diff --git a/src-ts/lib/form/form-inputs/form-input/input-rating/InputRating.module.scss b/src-ts/lib/form/form-groups/form-input/input-rating/InputRating.module.scss similarity index 100% rename from src-ts/lib/form/form-inputs/form-input/input-rating/InputRating.module.scss rename to src-ts/lib/form/form-groups/form-input/input-rating/InputRating.module.scss diff --git a/src-ts/lib/form/form-inputs/form-input/input-rating/InputRating.tsx b/src-ts/lib/form/form-groups/form-input/input-rating/InputRating.tsx similarity index 94% rename from src-ts/lib/form/form-inputs/form-input/input-rating/InputRating.tsx rename to src-ts/lib/form/form-groups/form-input/input-rating/InputRating.tsx index 69fb94770..721745978 100644 --- a/src-ts/lib/form/form-inputs/form-input/input-rating/InputRating.tsx +++ b/src-ts/lib/form/form-groups/form-input/input-rating/InputRating.tsx @@ -1,10 +1,8 @@ -import classNames from 'classnames' import { ChangeEvent, createRef, Dispatch, FC, - MouseEvent, RefObject, SetStateAction, useEffect, @@ -22,6 +20,7 @@ interface InputRatingProps { readonly dirty?: boolean readonly disabled?: boolean readonly error?: string + readonly hideInlineErrors?: boolean readonly name: string readonly onChange: (event: ChangeEvent) => void readonly tabIndex: number @@ -61,7 +60,11 @@ const InputRating: FC = (props: InputRatingProps) => { inputRef.current.dispatchEvent(new Event('input', { bubbles: true })) } - }, [rating]) + }, [ + inputRef, + props.value, + rating, + ]) return ( = (props: InputRatingProps) => { label={''} type='rating' className={styles['rating-input-wrapper']} + hideInlineErrors={props.hideInlineErrors} >
{stars} diff --git a/src-ts/lib/form/form-inputs/form-input/input-rating/index.ts b/src-ts/lib/form/form-groups/form-input/input-rating/index.ts similarity index 100% rename from src-ts/lib/form/form-inputs/form-input/input-rating/index.ts rename to src-ts/lib/form/form-groups/form-input/input-rating/index.ts diff --git a/src-ts/lib/form/form-groups/form-input/input-text/InputText.module.scss b/src-ts/lib/form/form-groups/form-input/input-text/InputText.module.scss new file mode 100644 index 000000000..7b47d447d --- /dev/null +++ b/src-ts/lib/form/form-groups/form-input/input-text/InputText.module.scss @@ -0,0 +1,38 @@ +@use '../../../../styles/typography'; +@import '../../../../styles/includes'; + +.form-input-text { + @extend .body-small; + color: $black-60; + box-sizing: border-box; + border: 0; + width: 100%; + padding: 0; + margin: 0; + height: auto; + border-radius: 0; + + &:focus { + box-shadow: none; + border: none; + outline: none; + color: $black-100; + } + + &:disabled { + background-color: $black-10; + } + + &::placeholder { + color: $black-60; + opacity: 1; + text-transform: none; + } + + &.checkbox { + & { + width: 20px; + height: 20px; + } + } +} diff --git a/src-ts/lib/form/form-inputs/form-input/input-text/InputText.tsx b/src-ts/lib/form/form-groups/form-input/input-text/InputText.tsx similarity index 76% rename from src-ts/lib/form/form-inputs/form-input/input-text/InputText.tsx rename to src-ts/lib/form/form-groups/form-input/input-text/InputText.tsx index ad9566726..21e94e307 100644 --- a/src-ts/lib/form/form-inputs/form-input/input-text/InputText.tsx +++ b/src-ts/lib/form/form-groups/form-input/input-text/InputText.tsx @@ -1,3 +1,4 @@ +import cn from 'classnames' import { FC, FocusEvent } from 'react' import { FormInputAutocompleteOption } from '../form-input-autcomplete-option.enum' @@ -5,20 +6,24 @@ import { InputWrapper } from '../input-wrapper' import styles from './InputText.module.scss' +export type InputTextTypes = 'checkbox' | 'password' | 'text' + interface InputTextProps { readonly autocomplete?: FormInputAutocompleteOption + readonly className?: string readonly dirty?: boolean readonly disabled?: boolean readonly error?: string + readonly hideInlineErrors?: boolean readonly hint?: string - readonly label?: string + readonly label?: string | JSX.Element readonly name: string - readonly onBlur: (event: FocusEvent) => void + readonly onBlur?: (event: FocusEvent) => void readonly onChange: (event: FocusEvent) => void readonly placeholder?: string readonly spellCheck?: boolean readonly tabIndex: number - readonly type: 'password' | 'text' + readonly type: InputTextTypes readonly value?: string | number } @@ -30,10 +35,11 @@ const InputText: FC = (props: InputTextProps) => { dirty={!!props.dirty} disabled={!!props.disabled} label={props.label || props.name} + hideInlineErrors={props.hideInlineErrors} > = (props: InputTextareaProps) => { disabled={!!props.disabled} label={props.label || props.name} type='textarea' + hideInlineErrors={props.hideInlineErrors} >