diff --git a/.circleci/config.yml b/.circleci/config.yml index dd0d77bc6..1c16fe79b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -8,6 +8,10 @@ deploy_defaults: &deploy_defaults docker: - image: cimg/python:3.10.2 +test_defaults: &test_defaults + docker: + - image: cypress/browsers:node16.14.2-slim-chrome100-ff99-edge + install_build_dependency: &install_build_dependency name: Installation of build and deployment dependencies. command: | @@ -50,6 +54,14 @@ running_yarn_build: &running_yarn_build yarn install yarn build +running_yarn_test: &running_yarn_test + name: Running Yarn Test Build + command: | + yarn install + yarn cypress install + yarn build + yarn cy:ci + workspace_persist: &workspace_persist root: . paths: @@ -81,6 +93,27 @@ build_steps: &build_steps - run: *running_yarn_build - persist_to_workspace: *workspace_persist +test_steps: &test_steps + # Initialization. + - checkout + - setup_remote_docker + - restore_cache: + key: test-node-modules-{{ checksum "yarn.lock" }} + - run: *running_yarn_test + - save_cache: + key: test-node-modules-{{ checksum "yarn.lock" }} + paths: + - node_modules + - /root/.cache/Cypress + - store_test_results: + path: cypress/test-report + - store_artifacts: + path: cypress/test-report + - store_artifacts: + path: cypress/videos + - store_artifacts: + path: cypress/screenshots + deploy_steps: &deploy_steps - checkout - attach_workspace: *workspace_attach @@ -127,6 +160,14 @@ jobs: LOGICAL_ENV: "prod" APPNAME: "platform-ui-mvp" steps: *build_steps + + test-dev: + <<: *test_defaults + environment: + DEPLOY_ENV: "DEV" + LOGICAL_ENV: "dev" + APPNAME: "platform-ui-mvp" + steps: *test_steps # Just tests commited code. deployDev: @@ -147,35 +188,6 @@ jobs: APPNAME: "platform-ui-mvp" steps: *deploy_steps - # Test job for the cases when we don not need deployment. - e2e-test: - docker: - - image: cypress/browsers:node16.14.2-slim-chrome100-ff99-edge - steps: - - checkout - - restore_cache: - key: test-node-modules-{{ checksum "yarn.lock" }} - - run: - name: Config Git - command: git config --global url."https://git@".insteadOf git:// - - run: - name: Install Dependencies - command: yarn install - no_output_timeout: 20m - - run: - name: Install Cypress Binary - command: yarn cypress install - - run: - name: Build the application - command: yarn build - no_output_timeout: 20m - - save_cache: - key: test-node-modules-{{ checksum "yarn.lock" }} - paths: - - node_modules - - /root/.cache/Cypress - - run: yarn cy:ci - workflows: version: 2 build: @@ -201,9 +213,6 @@ workflows: ignore: - master - - e2e-test: - context : org-global - - build-prod: context : org-global filters: @@ -215,7 +224,6 @@ workflows: context : org-global requires: - build-dev - - e2e-test filters: branches: only: @@ -229,3 +237,6 @@ workflows: branches: only: - master + + - test-dev: + context : org-global \ No newline at end of file diff --git a/.gitignore b/.gitignore index 63c983693..6d23f4087 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ # testing /coverage .nyc_output +/cypress/screenshots +/cypress/videos +/cypress/test-report # production /build diff --git a/README.md b/README.md index f3d7134a8..cd5757f15 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,14 @@ You can verify the versions of `nvm`, `node`, and `npm` using the commands below | `% nvm current` | v15.15.0 | +>**NOTE:** The `yarn start` command requires the `NVM_DIR` env variable is set. + +```zsh +export NVM_DIR=~/.nvm +``` + +If you don't have this set globally, you can create your own [personal config](#personal-config) to define your local nvm dir. + ### Hosting You will need to add the following line to your hosts file. The hosts file is normally located at `/etc/hosts` (Mac). Do not overwrite the existing localhost entry also pointing to 127.0.0.1. @@ -81,9 +89,9 @@ You will need to add the following line to your hosts file. The hosts file is no >% yarn start -3. Go to https://local.topcoder-dev.com:3000 +3. Go to https://local.topcoder-dev.com ->**NOTE**: The default port is 3000, but you can override it in your [personal config](#personal-config). +>**NOTE**: The site must run on port 443 in order for auth0 to work and for the site to load properly. Mac users will need to run the app with elevated permissions. ### Local SSL @@ -102,7 +110,7 @@ Otherwise, you will need to override the exception each time you load the site. ### Personal Config 1. Add [hostname] to [`/src-ts/config/environments/app-host-environment.type.ts`](/src-ts/config/environments/app-host-environment.type.ts) -2. Copy an existing config from [`/src-ts/config/environments/environment.*.config.ts`](/src-ts/config/environments/environment.bsouza.config.ts) +2. Copy an existing config from [`/src-ts/config/environments/environment.*.config.ts`](/src-ts/config/environments/environment.brooke.config.ts) 3. Rename new config `environment.[hostname].config.ts` 4. Rename config variable to `EnvironmentConfig[HostName]` 5. Set the `ENV` variable to `[hostname]` @@ -509,3 +517,5 @@ e.g.: } } ``` + +>**NOTE** - all SVGs require explicit `width` and `height` in the Safari browser in order to be rendered properly, otherwise they'll be rendered to the _default_ size and probably will crop out of view diff --git a/cypress.config.ts b/cypress.config.ts index e622ea360..fc55f43d8 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,19 +1,33 @@ +// tslint:disable-next-line: no-submodule-imports This is the way cypress does it +import task from '@cypress/code-coverage/task' import { defineConfig } from 'cypress' export default defineConfig({ - fixturesFolder: false, - video: false, - screenshotOnRunFailure: false, - defaultCommandTimeout: 10000, - e2e: { - baseUrl: 'http://localhost:3000', - specPattern: "cypress/e2e/**/*.spec.{js,jsx,ts,tsx}", - supportFile: "cypress/support/e2e.ts", - viewportHeight: 1000, - viewportWidth: 1280, - setupNodeEvents(on, config) { - require('@cypress/code-coverage/task')(on, config) - return config; + defaultCommandTimeout: 10000, + e2e: { + // baseUrl: 'https://local.topcoder-dev.com', + baseUrl: 'http://localhost:3000', + setupNodeEvents: setUpNodeEvents, + specPattern: 'cypress/e2e/**/*.spec.{js,jsx,ts,tsx}', + supportFile: 'cypress/support/e2e.ts', + viewportHeight: 1000, + viewportWidth: 1280, }, - }, + fixturesFolder: false, + reporter: 'junit', + reporterOptions: { + mochaFile: 'cypress/test-report/test-result-[hash].xml', + toConsole: false, + }, + screenshotOnRunFailure: true, + video: true, }) + +// adds the config to node setup events +function setUpNodeEvents( + on: Cypress.PluginEvents, + config: Cypress.PluginConfigOptions +): Cypress.PluginConfigOptions { + task(on, config) + return config +} diff --git a/cypress/e2e/home/home.spec.ts b/cypress/e2e/home/home.spec.ts index 8d1f98c25..43c584180 100644 --- a/cypress/e2e/home/home.spec.ts +++ b/cypress/e2e/home/home.spec.ts @@ -5,4 +5,8 @@ describe('Landing Page', () => { it('loads landing page should be successfully', () => { cy.get('[data-id="root"]').should('be.visible') }) + + it.skip('loads landing page should fail', () => { + cy.get('[data-id="root"]').should('not.be.visible') + }) }) diff --git a/package.json b/package.json index bf9628928..dea08d778 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,9 @@ "scripts": { "dev": "yarn react-app-rewired start", "start": "sh start-ssl.sh", - "start:bsouza": "sudo sh start-ssl-bsouza.sh", + "start:brooke": "sudo sh start-ssl-brooke.sh", "build": "yarn react-app-rewired build", + "postbuild": "gzip build/static/js/*.js && gzip build/static/css/*.css", "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}'", @@ -15,7 +16,7 @@ "eslint:fix": "eslint 'src/**/*.{js,jsx}' --fix", "test": "react-scripts test --watchAll", "test:no-watch": "react-scripts test --watchAll=false --passWithNoTests", - "cy:run": "cypress run", + "cy:run": "cypress run --reporter junit", "cy:ci": "start-server-and-test 'serve -s build -n -p 3000' http://localhost:3000 'cy:run'", "report:coverage": "nyc report --reporter=html", "report:coverage:text": "nyc report --reporter=text" @@ -23,9 +24,6 @@ "dependencies": { "@datadog/browser-logs": "^4.7.1", "@heroicons/react": "^1.0.6", - "@types/dompurify": "^2.3.3", - "@types/highlightjs": "^9.12.2", - "@types/marked": "4.0.3", "apexcharts": "^3.35.3", "axios": "^0.26.1", "browser-cookies": "^1.2.0", @@ -37,14 +35,17 @@ "highlight.js": "^11.6.0", "html2canvas": "^1.4.1", "lodash": "^4.17.21", + "markdown-it": "^13.0.1", "marked": "4.0.3", "moment": "^2.29.3", "moment-timezone": "^0.5.34", "prop-types": "^15.8.1", + "qs": "^6.11.0", "rc-checkbox": "^2.3.2", "react": "^17.0.2", "react-apexcharts": "^1.4.0", "react-app-rewired": "^2.2.1", + "react-contenteditable": "^3.3.6", "react-dom": "^17.0.2", "react-elastic-carousel": "^0.11.5", "react-gtm-module": "^2.0.11", @@ -63,6 +64,7 @@ "redux-thunk": "^2.4.1", "sass": "^1.49.8", "styled-components": "^5.3.5", + "swr": "^1.3.0", "tc-auth-lib": "topcoder-platform/tc-auth-lib#1.0.4", "typescript": "^4.6.3", "uuid": "^8.3.2" @@ -82,8 +84,13 @@ "@testing-library/react": "^12.0.0", "@testing-library/user-event": "^13.2.1", "@types/axios": "^0.14.0", + "@types/cypress": "^1.1.3", + "@types/dompurify": "^2.3.3", + "@types/highlightjs": "^9.12.2", "@types/jest": "^27.0.1", "@types/lodash": "^4.14.182", + "@types/markdown-it": "^12.2.3", + "@types/marked": "4.0.3", "@types/node": "^18.7.13", "@types/reach__router": "^1.3.10", "@types/react": "^18.0.5", @@ -94,6 +101,7 @@ "@types/segment-analytics": "^0.0.34", "@types/systemjs": "^6.1.0", "@types/uuid": "^8.3.4", + "@wdio/junit-reporter": "^7.24.0", "autoprefixer": "^9.8.6", "babel-eslint": "^11.0.0-beta.2", "babel-jest": "^24.9.0", diff --git a/src-ts/config/environments/app-host-environment.type.ts b/src-ts/config/environments/app-host-environment.type.ts index 01efb080c..193be0f27 100644 --- a/src-ts/config/environments/app-host-environment.type.ts +++ b/src-ts/config/environments/app-host-environment.type.ts @@ -1 +1 @@ -export type AppHostEnvironmentType = 'bsouza' | 'default' | 'dev' | 'prod' +export type AppHostEnvironmentType = 'brooke' | 'default' | 'dev' | 'prod' diff --git a/src-ts/config/environments/environment.bsouza.config.ts b/src-ts/config/environments/environment.brooke.config.ts similarity index 68% rename from src-ts/config/environments/environment.bsouza.config.ts rename to src-ts/config/environments/environment.brooke.config.ts index 269db98cc..a614d691f 100644 --- a/src-ts/config/environments/environment.bsouza.config.ts +++ b/src-ts/config/environments/environment.brooke.config.ts @@ -1,7 +1,7 @@ import { EnvironmentConfigModel } from './environment-config.model' import { EnvironmentConfigDefault } from './environment.default.config' -export const EnvironmentConfigBsouza: EnvironmentConfigModel = { +export const EnvironmentConfigBrooke: EnvironmentConfigModel = { ...EnvironmentConfigDefault, - ENV: 'bsouza', + ENV: 'brooke', } diff --git a/src-ts/config/environments/environment.config.ts b/src-ts/config/environments/environment.config.ts index b0312a180..25fca7939 100644 --- a/src-ts/config/environments/environment.config.ts +++ b/src-ts/config/environments/environment.config.ts @@ -1,6 +1,6 @@ import { AppHostEnvironmentType } from './app-host-environment.type' import { EnvironmentConfigModel } from './environment-config.model' -import { EnvironmentConfigBsouza } from './environment.bsouza.config' +import { EnvironmentConfigBrooke } from './environment.brooke.config' import { EnvironmentConfigDefault } from './environment.default.config' import { EnvironmentConfigDev } from './environment.dev.config' import { EnvironmentConfigProd } from './environment.prod.config' @@ -12,8 +12,8 @@ function getEnvironmentConfig(): EnvironmentConfigModel { switch (environment) { - case 'bsouza': - return EnvironmentConfigBsouza + case 'brooke': + return EnvironmentConfigBrooke case 'default': return EnvironmentConfigDefault diff --git a/src-ts/declarations.d.ts b/src-ts/declarations.d.ts index bd7a8df0c..84ecabc9c 100644 --- a/src-ts/declarations.d.ts +++ b/src-ts/declarations.d.ts @@ -1,3 +1,5 @@ +declare module '@cypress/code-coverage/task' + declare module '*.html' { const htmlFile: string export = htmlFile diff --git a/src-ts/lib/form/Form.tsx b/src-ts/lib/form/Form.tsx index c1aa827d1..08bbc90c2 100644 --- a/src-ts/lib/form/Form.tsx +++ b/src-ts/lib/form/Form.tsx @@ -35,6 +35,8 @@ interface FormProps { readonly onChange?: (inputs: ReadonlyArray) => void, readonly onSuccess?: () => void readonly requestGenerator: (inputs: ReadonlyArray) => RequestType + readonly resetFormAfterSave?: boolean + readonly resetFormOnUnmount?: boolean readonly save: (value: RequestType) => Promise } @@ -83,6 +85,14 @@ const Form: (props: FormProps { + return () => { + if (props.resetFormOnUnmount) { + onReset() + } + } + }, []) + function checkIfFormIsValid(formInputFields: Array): void { setFormInvalid(formInputFields.filter(item => !!item.error).length > 0) } @@ -111,16 +121,21 @@ const Form: (props: FormProps): Promise { const values: RequestType = props.requestGenerator(inputs) formOnSubmitAsync(props.action || 'submit', event, formDef, values, props.save, props.onSuccess) .then(() => { - setFormKey(Date.now()) - formOnReset(inputs, props.formValues) - setFormDef({ ...formDef }) - setInputs(formGetInputFields(formDef.groups || [])) + if (!props.resetFormAfterSave) { + setFormKey(Date.now()) + formOnReset(inputs, props.formValues) + setFormDef({ ...formDef }) + setInputs(formGetInputFields(formDef.groups || [])) + } else { + onReset() + } }) .catch((error: string | undefined) => { setFormError(error) diff --git a/src-ts/lib/form/form-definition.model.ts b/src-ts/lib/form/form-definition.model.ts index fef7d8c58..efd292f8f 100644 --- a/src-ts/lib/form/form-definition.model.ts +++ b/src-ts/lib/form/form-definition.model.ts @@ -1,4 +1,4 @@ -import { FormButton, FormGroup } from '.' +import { FormButton, FormGroup, FormGroupOptions } from '.' export type FormAction = 'save' | 'submit' | undefined @@ -10,6 +10,7 @@ export interface FormButtons { export interface FormDefinition { readonly buttons: FormButtons readonly groups?: Array + readonly groupsOptions?: FormGroupOptions 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 5f5e72246..1c4eaf1a3 100644 --- a/src-ts/lib/form/form-functions/form.functions.ts +++ b/src-ts/lib/form/form-functions/form.functions.ts @@ -33,9 +33,13 @@ export function initializeValues(inputs: Array, formValues?: inputs .filter(input => !input.dirty && !input.touched) .forEach(input => { - input.value = !!(formValues as any)?.hasOwnProperty(input.name) - ? (formValues as any)[input.name] - : undefined + if (input.type === 'checkbox') { + input.value = input.checked || false + } else { + input.value = !!(formValues as any)?.hasOwnProperty(input.name) + ? (formValues as any)[input.name] + : undefined + } }) } @@ -64,6 +68,7 @@ export async function onSubmitAsync( formValue: T, save: (value: T) => Promise, onSuccess?: () => void, + customValidateForm?: (formElements: HTMLFormControlsCollection, event: ValidationEvent, inputs: ReadonlyArray) => boolean ): Promise { event.preventDefault() @@ -80,7 +85,7 @@ export async function onSubmitAsync( // want to have it look like the submit succeeded const formValues: HTMLFormControlsCollection = (event.target as HTMLFormElement).elements if (action === 'submit') { - const isValid: boolean = validateForm(formValues, action, inputs) + const isValid: boolean = (customValidateForm || validateForm)(formValues, action, inputs) if (!isValid) { return Promise.reject() } @@ -115,13 +120,22 @@ function handleFieldEvent(input: HTMLInputElement | HTMLTextAreaElement, inpu const inputDef: FormInputModel = getInputModel(inputs, input.name) + const inputEl: HTMLInputElement = input as HTMLInputElement + if (event === 'change') { inputDef.dirty = input.value !== originalValue } inputDef.touched = true // set the def value - inputDef.value = input.value + if (input.type === 'checkbox') { + inputDef.value = inputEl.checked + inputDef.checked = inputEl.checked + } else if (input.type === 'file') { + inputDef.value = inputEl.files || undefined + } else { + inputDef.value = input.value + } // now let's validate the field const formElements: HTMLFormControlsCollection = (input.form as HTMLFormElement).elements @@ -171,7 +185,9 @@ function validateField(formInputDef: FormInputModel, formElements: HTMLFormContr }) } -export function validateForm(formElements: HTMLFormControlsCollection, event: 'blur' | 'change' | 'submit' | 'initial', inputs: ReadonlyArray): boolean { +export type ValidationEvent = 'blur' | 'change' | 'submit' | 'initial' + +export function validateForm(formElements: HTMLFormControlsCollection, event: ValidationEvent, inputs: ReadonlyArray): boolean { const errors: ReadonlyArray = inputs?.filter(formInputDef => { formInputDef.dirty = formInputDef.dirty || event === 'submit' validateField(formInputDef, formElements, event) diff --git a/src-ts/lib/form/form-group.model.ts b/src-ts/lib/form/form-group.model.ts index 7fe97df9f..7f90a5418 100644 --- a/src-ts/lib/form/form-group.model.ts +++ b/src-ts/lib/form/form-group.model.ts @@ -1,3 +1,5 @@ +import { CSSProperties } from 'react' + import { FormInputModel } from './form-input.model' export interface FormGroup { @@ -6,3 +8,8 @@ export interface FormGroup { readonly instructions?: string readonly title?: string } + +export interface FormGroupOptions { + groupWrapStyles?: CSSProperties + renderGroupDividers?: boolean +} diff --git a/src-ts/lib/form/form-groups/FormGroups.module.scss b/src-ts/lib/form/form-groups/FormGroups.module.scss index 6c3e7a4b0..3bf262416 100644 --- a/src-ts/lib/form/form-groups/FormGroups.module.scss +++ b/src-ts/lib/form/form-groups/FormGroups.module.scss @@ -1,5 +1,11 @@ +@import "../../styles/includes"; + .form-groups { display: grid; grid-template-columns: 1fr; justify-content: center; + + @include ltemd { + grid-template-columns: 1fr !important; + } } diff --git a/src-ts/lib/form/form-groups/FormGroups.tsx b/src-ts/lib/form/form-groups/FormGroups.tsx index 2562d0146..868c24af3 100644 --- a/src-ts/lib/form/form-groups/FormGroups.tsx +++ b/src-ts/lib/form/form-groups/FormGroups.tsx @@ -1,12 +1,13 @@ import { ChangeEvent, FocusEvent } from 'react' +import { PageDivider } from '../../page-divider' 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 { InputImagePicker, 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' @@ -23,15 +24,18 @@ const FormGroups: (props: FormGroupsProps) => JSX.Element = (props: FormGroupsPr const { formDef, onBlur, onChange }: FormGroupsProps = props - const getTabIndex: (input: FormInputModel, index: number) => number = (input, index) => { + function getTabIndex(input: FormInputModel, index: number): number { const tabIndex: number = input.notTabbable ? -1 : index + 1 + (formDef.tabIndexStart || 0) return tabIndex } - const renderInputField: (input: FormInputModel, index: number) => JSX.Element | undefined = (input, index) => { + function renderInputField(input: FormInputModel, index: number): JSX.Element | undefined { + const tabIndex: number = getTabIndex(input, index) let inputElement: JSX.Element + + /* tslint:disable:cyclomatic-complexity */ switch (input.type) { case 'rating': @@ -40,11 +44,10 @@ const FormGroups: (props: FormGroupsProps) => JSX.Element = (props: FormGroupsPr {...input} onChange={onChange} tabIndex={tabIndex} - value={input.value} + value={input.value as number | undefined} /> ) break - case 'textarea': inputElement = ( JSX.Element = (props: FormGroupsPr onBlur={onBlur} onChange={onChange} tabIndex={tabIndex} - value={input.value} + value={input.value as string | undefined} /> ) break case 'checkbox': + inputElement = ( + + ) + break case 'radio': inputElement = ( JSX.Element = (props: FormGroupsPr /> ) break + case 'image-picker': + inputElement = ( + + ) + break default: inputElement = ( JSX.Element = (props: FormGroupsPr onChange={onChange} tabIndex={tabIndex} type={input.type as InputTextTypes || 'text'} - value={input.value} + value={input.value as string | undefined} /> ) break @@ -100,14 +123,29 @@ const FormGroups: (props: FormGroupsProps) => JSX.Element = (props: FormGroupsPr ) } - const formGroups: Array = formDef?.groups?.map((element: FormGroup, index: number) => { - return - }) || [] + const formGroups: Array = formDef?.groups + ?.map((element: FormGroup, index: number) => { + return ( + + ) + }) + || [] return ( -
- {formGroups} -
+ <> +
+ {formGroups} +
+ { + props.formDef.groupsOptions?.renderGroupDividers === false && + } + ) } 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 index 41a568ec9..3b8840156 100644 --- a/src-ts/lib/form/form-groups/form-group-item/FormGroupItem.tsx +++ b/src-ts/lib/form/form-groups/form-group-item/FormGroupItem.tsx @@ -9,6 +9,7 @@ import styles from './FormGroupItem.module.scss' interface FormGroupItemProps { group: FormGroup + renderDividers?: boolean renderFormInput: (input: FormInputModel, index: number) => JSX.Element | undefined totalGroupCount: number } @@ -19,10 +20,11 @@ interface ItemRowProps { hasMultipleGroups: boolean, instructions?: string | undefined, isMultiFieldGroup: boolean, + renderDividers?: boolean title?: string, } -const TwoColumnItem: React.FC = ({ element, formInputs, hasMultipleGroups, instructions, isMultiFieldGroup, title }: ItemRowProps) => { +const TwoColumnItem: React.FC = ({ element, formInputs, hasMultipleGroups, instructions, isMultiFieldGroup, title, renderDividers }: ItemRowProps) => { return ( <>
@@ -41,7 +43,9 @@ const TwoColumnItem: React.FC = ({ element, formInputs, hasMultipl {formInputs}
- + { + renderDividers !== false && + } ) } @@ -67,7 +71,7 @@ const SingleColumnItem: React.FC = ({ formInputs, hasMultipleGroup ) } -const FormGroupItem: React.FC = ({ group, renderFormInput, totalGroupCount }: FormGroupItemProps) => { +const FormGroupItem: React.FC = ({ group, renderDividers, renderFormInput, totalGroupCount }: FormGroupItemProps) => { const { instructions, title, inputs, element }: FormGroup = group const formInputs: Array = inputs?.map((field: FormInputModel, index: number) => renderFormInput(field as FormInputModel, index)) || [] @@ -77,7 +81,7 @@ const FormGroupItem: React.FC = ({ group, renderFormInput, t return isCardSet ? : - + } export default FormGroupItem diff --git a/src-ts/lib/form/form-groups/form-input/index.ts b/src-ts/lib/form/form-groups/form-input/index.ts index 3f5dfa642..4e43914cb 100644 --- a/src-ts/lib/form/form-groups/form-input/index.ts +++ b/src-ts/lib/form/form-groups/form-input/index.ts @@ -1,5 +1,7 @@ +export * from './input-image-picker' export * from './form-input-autcomplete-option.enum' export * from './input-rating' +export * from './input-select' export * from './input-text' export * from './input-textarea' export { inputOptional } from './input-wrapper' diff --git a/src-ts/lib/form/form-groups/form-input/input-image-picker/InputImagePicker.module.scss b/src-ts/lib/form/form-groups/form-input/input-image-picker/InputImagePicker.module.scss new file mode 100644 index 000000000..5641206e7 --- /dev/null +++ b/src-ts/lib/form/form-groups/form-input/input-image-picker/InputImagePicker.module.scss @@ -0,0 +1,40 @@ +@import "../../../../styles/includes"; +@import "../../../../styles/variables"; + +.filePicker { + display: flex; + align-items: center; + justify-content: center; + background-color: $black-5; + border-radius: 8px; + width: 132px; + height: 132px; + position: relative; + margin-bottom: $space-xl; + + @include ltemd { + width: 100%; + } + + .filePickerPlaceholder { + color: $turq-160; + text-align: center; + font-weight: $font-weight-bold; + } + + .filePickerPencil { + position: absolute; + top: 0; + right: 0; + color: $turq-160; + } + + .filePickerInput { + display: none; + } + + .badgeImage { + width: 72px; + height: 72px; + } +} \ No newline at end of file diff --git a/src-ts/lib/form/form-groups/form-input/input-image-picker/InputImagePicker.tsx b/src-ts/lib/form/form-groups/form-input/input-image-picker/InputImagePicker.tsx new file mode 100644 index 000000000..d724b8ade --- /dev/null +++ b/src-ts/lib/form/form-groups/form-input/input-image-picker/InputImagePicker.tsx @@ -0,0 +1,74 @@ +import { ChangeEvent, createRef, Dispatch, FC, RefObject, SetStateAction, useEffect, useState } from 'react' + +import { Button, IconOutline } from '../../../../../lib' +import { InputValue } from '../../../form-input.model' + +import styles from './InputImagePicker.module.scss' + +interface InputImagePickerProps { + readonly fileConfig?: { + readonly acceptFileType?: string + readonly maxFileSize?: number + } + readonly name: string + readonly onChange: (event: ChangeEvent) => void + readonly value?: InputValue +} + +const InputImagePicker: FC = (props: InputImagePickerProps) => { + + const fileInputRef: RefObject = createRef() + + // tslint:disable-next-line:no-null-keyword + const [files, setFiles]: [FileList | null, Dispatch>] = useState(null) + const [fileDataURL, setFileDataURL]: [string | undefined, Dispatch>] = useState() + + useEffect(() => { + if (files && files.length) { + const fileReader: FileReader = new FileReader() + fileReader.onload = e => { + const { result }: any = e.target + if (result) { + setFileDataURL(result) + } + } + fileReader.readAsDataURL(files[0]) + } else if (fileDataURL) { + setFileDataURL(undefined) + } + }, [ + files, + fileDataURL, + ]) + + return ( +
+
+ ) +} + +export default InputImagePicker diff --git a/src-ts/lib/form/form-groups/form-input/input-image-picker/index.ts b/src-ts/lib/form/form-groups/form-input/input-image-picker/index.ts new file mode 100644 index 000000000..ceeb9aa9d --- /dev/null +++ b/src-ts/lib/form/form-groups/form-input/input-image-picker/index.ts @@ -0,0 +1 @@ +export { default as InputImagePicker } from './InputImagePicker' diff --git a/src-ts/lib/form/form-groups/form-input/input-select/InputSelect.module.scss b/src-ts/lib/form/form-groups/form-input/input-select/InputSelect.module.scss new file mode 100644 index 000000000..7d4c73668 --- /dev/null +++ b/src-ts/lib/form/form-groups/form-input/input-select/InputSelect.module.scss @@ -0,0 +1,57 @@ +@import '../../../../styles/includes'; + +.selected { + display: flex; + align-items: center; + margin-top: $space-xs; + cursor: pointer; + color: $black-100; + + &-icon { + margin-left: auto; + padding: $border-xs 0; + color: $turq-160; + > svg { + @include icon-size(14); + } + } +} + +.menu-wrap { + position: absolute; + top: calc(100% - 2px); + left: 0; + width: 100%; + &:not(:empty) { + z-index: 9; + } +} + +.select-menu { + position: absolute; + top: 100%; + left: -1px; + right: -1px; + background: $tc-white; + border: $border-xs solid $black-40; + border-radius: 0 0 $space-xs $space-xs; + padding: $space-sm 0; + max-height: 230px; + overflow: auto; +} + +.select-option { + font-weight: normal; + color: $black-100; + padding: $space-sm $space-lg; + + &:hover:global(:not(.selected)) { + background: $turq-160; + color: $tc-white; + cursor: pointer; + } + + &:global(.selected) { + font-weight: bold; + } +} diff --git a/src-ts/lib/form/form-groups/form-input/input-select/InputSelect.tsx b/src-ts/lib/form/form-groups/form-input/input-select/InputSelect.tsx new file mode 100644 index 000000000..f9da6acf8 --- /dev/null +++ b/src-ts/lib/form/form-groups/form-input/input-select/InputSelect.tsx @@ -0,0 +1,104 @@ +import classNames from 'classnames' +import { + ChangeEvent, + Dispatch, + FC, + MutableRefObject, + ReactNode, + SetStateAction, + useRef, + useState, +} from 'react' + +import { useClickOutside } from '../../../../hooks' +import { IconOutline } from '../../../../svgs' +import { InputWrapper } from '../input-wrapper' + +import styles from './InputSelect.module.scss' + +export interface InputSelectOption { + label?: ReactNode + value: string +} + +interface InputSelectProps { + readonly dirty?: boolean + readonly disabled?: boolean + readonly error?: string + readonly hideInlineErrors?: boolean + readonly hint?: string + readonly label?: string + readonly name: string + readonly onChange: (event: ChangeEvent) => void + readonly options: Array + readonly tabIndex?: number + readonly value?: string +} + +const InputSelect: FC = (props: InputSelectProps) => { + const triggerRef: MutableRefObject = useRef(undefined) + const [menuIsVisible, setMenuIsVisible]: [boolean, Dispatch>] = useState(false) + + const selectedOption: InputSelectOption | undefined = props.options.find(option => option.value === props.value) + + const label: (option: InputSelectOption) => ReactNode = (option?: InputSelectOption) => ( + option ? option.label ?? option.value : '' + ) + + const toggleMenu: () => void = () => setMenuIsVisible((wasVisible) => !wasVisible) + + const select: (option: InputSelectOption) => () => void = (option: InputSelectOption) => () => { + props.onChange({ + target: {value: option.value} , + } as unknown as ChangeEvent) + toggleMenu() + } + + useClickOutside(triggerRef.current, () => setMenuIsVisible(false)) + + return ( + +
+ {selectedOption ? label(selectedOption) : ''} + + + +
+ +
+ {menuIsVisible && ( +
+ {props.options.map((option) => ( +
+ {label(option)} +
+ ))} +
+ )} +
+ +
+ ) +} + +export default InputSelect diff --git a/src-ts/lib/form/form-groups/form-input/input-select/index.ts b/src-ts/lib/form/form-groups/form-input/input-select/index.ts new file mode 100644 index 000000000..605f28a24 --- /dev/null +++ b/src-ts/lib/form/form-groups/form-input/input-select/index.ts @@ -0,0 +1 @@ +export { default as InputSelect } from './InputSelect' 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 index 7b47d447d..ac7f33476 100644 --- 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 @@ -30,9 +30,32 @@ } &.checkbox { - & { - width: 20px; - height: 20px; + @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; + } + + &.checkbox { + & { + width: 20px; + height: 20px; + } } } } diff --git a/src-ts/lib/form/form-groups/form-input/input-text/InputText.tsx b/src-ts/lib/form/form-groups/form-input/input-text/InputText.tsx index 21e94e307..a88c96fb5 100644 --- a/src-ts/lib/form/form-groups/form-input/input-text/InputText.tsx +++ b/src-ts/lib/form/form-groups/form-input/input-text/InputText.tsx @@ -1,6 +1,7 @@ import cn from 'classnames' import { FC, FocusEvent } from 'react' +import { InputValue } from '../../../form-input.model' import { FormInputAutocompleteOption } from '../form-input-autcomplete-option.enum' import { InputWrapper } from '../input-wrapper' @@ -8,8 +9,9 @@ import styles from './InputText.module.scss' export type InputTextTypes = 'checkbox' | 'password' | 'text' -interface InputTextProps { +export interface InputTextProps { readonly autocomplete?: FormInputAutocompleteOption + readonly checked?: boolean readonly className?: string readonly dirty?: boolean readonly disabled?: boolean @@ -24,11 +26,15 @@ interface InputTextProps { readonly spellCheck?: boolean readonly tabIndex: number readonly type: InputTextTypes - readonly value?: string | number + readonly value?: InputValue } const InputText: FC = (props: InputTextProps) => { + const defaultValue: string | number | undefined = props.type === 'checkbox' && !!props.checked + ? 'on' + : props.value as string | number | undefined + return ( = (props: InputTextProps) => { > = (props: InputWrapperProps) => { +const InputWrapper: ForwardRefExoticComponent = forwardRef((props: InputWrapperProps, ref) => { const [focusStyle, setFocusStyle]: [string | undefined, Dispatch>] = useState() @@ -28,6 +28,7 @@ const InputWrapper: FC = (props: InputWrapperProps) => { const showError: boolean = isShowError() const formFieldClasses: string = classNames( styles.input, + 'input-el', styles[props.type], props.disabled ? styles.disabled : undefined, focusStyle, @@ -43,8 +44,9 @@ const InputWrapper: FC = (props: InputWrapperProps) => { return (
= (props: InputWrapperProps) => { )}
) -} +}) export default InputWrapper diff --git a/src-ts/lib/form/form-input.model.ts b/src-ts/lib/form/form-input.model.ts index a27489902..419d63cb7 100644 --- a/src-ts/lib/form/form-input.model.ts +++ b/src-ts/lib/form/form-input.model.ts @@ -17,15 +17,23 @@ export interface FormCard { title: string } +export type InputValue = string | boolean | FileList | undefined + export interface FormInputModel { readonly autocomplete?: FormInputAutocompleteOption readonly cards?: ReadonlyArray + checked?: boolean readonly className?: string readonly dependentFields?: Array dirty?: boolean disabled?: boolean error?: string readonly events?: ReadonlyArray + readonly fileConfig?: { + readonly acceptFileType?: string + readonly maxFileSize?: number + } + readonly files?: FileList readonly hideInlineErrors?: boolean readonly hint?: string readonly id?: string @@ -38,7 +46,7 @@ export interface FormInputModel { readonly spellCheck?: boolean readonly title?: string touched?: boolean - readonly type: 'card-set' | 'checkbox' | 'password' | 'radio' | 'rating' | 'text' | 'textarea' + readonly type: 'card-set' | 'checkbox' | 'password' | 'radio' | 'rating' | 'text' | 'textarea' | 'image-picker' readonly validators?: ReadonlyArray - value?: string + value?: InputValue } diff --git a/src-ts/lib/form/index.ts b/src-ts/lib/form/index.ts index 4cc7d9d69..cc059c6f6 100644 --- a/src-ts/lib/form/index.ts +++ b/src-ts/lib/form/index.ts @@ -8,5 +8,5 @@ export { } from './form-functions' export * from './form-input.model' export * from './form-group.model' -export { inputOptional, FormInputAutocompleteOption } from './form-groups' +export * from './form-groups/form-input' export * from './validator-functions' diff --git a/src-ts/lib/form/validator-functions/validator.functions.ts b/src-ts/lib/form/validator-functions/validator.functions.ts index af1c9ad7a..dbf89559a 100644 --- a/src-ts/lib/form/validator-functions/validator.functions.ts +++ b/src-ts/lib/form/validator-functions/validator.functions.ts @@ -1,12 +1,13 @@ import { formGetInput } from '../form-functions' +import { InputValue } from '../form-input.model' -function checkForBooleanValueAndThrowError(value: string | boolean | undefined): void { +function checkForBooleanValueAndThrowError(value: InputValue): void { if (typeof value === 'boolean') { throw new Error(`The value for the email validator cannot be a boolean`) } } -export function doesNotMatchOther(value: string | boolean | undefined, formElements?: HTMLFormControlsCollection, otherFieldName?: string): string | undefined { +export function doesNotMatchOther(value: InputValue, formElements?: HTMLFormControlsCollection, otherFieldName?: string): string | undefined { checkForBooleanValueAndThrowError(value) @@ -27,7 +28,7 @@ export function doesNotMatchOther(value: string | boolean | undefined, formEleme return `Cannot match the ${getOtherFieldLabel(otherField, otherFieldName)} value` } -export function email(value: string | boolean | undefined): string | undefined { +export function email(value: InputValue): string | undefined { checkForBooleanValueAndThrowError(value) @@ -42,10 +43,10 @@ export function email(value: string | boolean | undefined): string | undefined { const emailRegex: RegExp = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ - return !emailRegex.test(value) ? 'Invalid email' : undefined + return !emailRegex.test(value as string) ? 'Invalid email' : undefined } -export function password(value: string | boolean | undefined): string | undefined { +export function password(value: InputValue): string | undefined { checkForBooleanValueAndThrowError(value) @@ -64,10 +65,10 @@ export function password(value: string | boolean | undefined): string | undefine // - at least 1 symbol or number const passwordRegex: RegExp = /^(?=.*[a-zA-Z])(?=.*[#$^+=!*()@%&\d]).{8,}$/g - return !passwordRegex.test(value) ? 'Password rules: 8+ characters, 1+ letter, and 1+ number or symbol' : undefined + return !passwordRegex.test(value as string) ? 'Password rules: 8+ characters, 1+ letter, and 1+ number or symbol' : undefined } -export function matchOther(value: string | boolean | undefined, formElements?: HTMLFormControlsCollection, otherFieldName?: string): string | undefined { +export function matchOther(value: InputValue, formElements?: HTMLFormControlsCollection, otherFieldName?: string): string | undefined { checkForBooleanValueAndThrowError(value) @@ -88,11 +89,11 @@ export function matchOther(value: string | boolean | undefined, formElements?: H return `Does not match the ${getOtherFieldLabel(otherField, otherFieldName)}` } -export function required(value: string | boolean | undefined): string | undefined { - return (value === undefined || value === '') ? 'Required' : undefined +export function required(value: InputValue): string | undefined { + return (value === undefined || value === '' || !(value as FileList).length) ? 'Required' : undefined } -export function requiredIfOther(value: string | boolean | undefined, formElements?: HTMLFormControlsCollection, otherFieldName?: string): string | undefined { +export function requiredIfOther(value: InputValue, formElements?: HTMLFormControlsCollection, otherFieldName?: string): string | undefined { // if there is a value, there's no need to check the other input if (typeof value === 'string' && !!value) { @@ -129,7 +130,7 @@ export function sslUrl(value: string | undefined): string | undefined { export interface ValidatorFn { dependentField?: string, - validator: (value: string | boolean | undefined, formValues?: HTMLFormControlsCollection, otherField?: string) => string | undefined + validator: (value: InputValue, formValues?: HTMLFormControlsCollection, otherField?: string) => string | undefined } function getOtherField(formElements?: HTMLFormControlsCollection, otherFieldName?: string): HTMLInputElement { diff --git a/src-ts/lib/functions/authentication-functions/authentication.functions.test.ts b/src-ts/lib/functions/authentication-functions/authentication.functions.test.ts deleted file mode 100644 index 8d3b4e806..000000000 --- a/src-ts/lib/functions/authentication-functions/authentication.functions.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import '@testing-library/jest-dom' - -describe('Authentication Functions', () => { - - test('authentication', () => { }) -}) diff --git a/src-ts/lib/functions/user-functions/user.functions.test.ts b/src-ts/lib/functions/user-functions/user.functions.test.ts deleted file mode 100644 index c77886f77..000000000 --- a/src-ts/lib/functions/user-functions/user.functions.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import '@testing-library/jest-dom' - -describe('Profile Functions', () => { - - test('TODO', () => { - - }) -}) diff --git a/src-ts/lib/functions/xhr-functions/xhr.functions.ts b/src-ts/lib/functions/xhr-functions/xhr.functions.ts index 8df50e55e..2ea1d30f7 100644 --- a/src-ts/lib/functions/xhr-functions/xhr.functions.ts +++ b/src-ts/lib/functions/xhr-functions/xhr.functions.ts @@ -74,6 +74,8 @@ function interceptError(instance: AxiosInstance): void { // if there is server error message, then return it inside `message` property of error error.message = error?.response?.data?.message || error.message + // if there is server errors data, then return it inside `errors` property of error + error.errors = error?.response?.data?.errors return Promise.reject(error) } diff --git a/src-ts/lib/global-config.model.ts b/src-ts/lib/global-config.model.ts index ddf9b8ce4..10fde8e9c 100644 --- a/src-ts/lib/global-config.model.ts +++ b/src-ts/lib/global-config.model.ts @@ -11,6 +11,9 @@ export interface GlobalConfig { } DISABLED_TOOLS?: Array ENV: string + GAMIFICATION: { + ORG_ID: string + }, LOGGING: { PUBLIC_TOKEN: string SERVICE: string diff --git a/src-ts/lib/hooks/index.ts b/src-ts/lib/hooks/index.ts index a71c9300e..920935edc 100644 --- a/src-ts/lib/hooks/index.ts +++ b/src-ts/lib/hooks/index.ts @@ -1,4 +1,5 @@ export * from './use-check-is-mobile.hook' export * from './use-click-outside.hook' export * from './use-on-hover-element.hook' +export * from './use-storage.hook' export * from './use-window-size.hook' diff --git a/src-ts/lib/hooks/use-storage.hook.ts b/src-ts/lib/hooks/use-storage.hook.ts new file mode 100644 index 000000000..282601eeb --- /dev/null +++ b/src-ts/lib/hooks/use-storage.hook.ts @@ -0,0 +1,69 @@ +import { Dispatch, SetStateAction, useCallback, useState } from 'react' + +type StorageTypes = 'localStorage' | 'sessionStorage' + +export function useStorage( + storageType: StorageTypes, + storageKey: string, + initialValue?: T +): [T, Dispatch>] { + const storage: Storage = window[storageType] + + const readStoredValue: () => T = useCallback(() => { + try { + // Get from local storage by key + const item: string | null = storage.getItem(storageKey) + // Parse stored json or if none return initialValue + return item ? JSON.parse(item) : initialValue + } catch (error) { + // If error also return value + return initialValue + } + }, [storage, storageKey, initialValue]) + + // State to store our value + // Pass initial state function to useState so logic is only executed once + const [storedValue, setStoredValue]: [T, Dispatch>] = useState(readStoredValue()) + + // Return a wrapped version of useState's setter function that + // persists the new value to local or session storage. + const setValue: Dispatch> = useCallback((value: T) => { + try { + // Allow value to be a function so we have same API as useState + setStoredValue((storedv: T) => { + const valueToStore: T = value instanceof Function ? value(storedv) : value + + if (valueToStore === undefined) { + storage.removeItem(storageKey) + } else { + // Save to local storage + storage.setItem(storageKey, JSON.stringify(valueToStore)) + } + + return valueToStore + }) + } catch (error) { + // A more advanced implementation would handle the error case + // tslint:disable-next-line:no-console + console.error(error) + } + }, [storage, storageKey]) as Dispatch> + + return [storedValue, setValue] +} + +export const useLocalStorage: ( + key: string, + initialValue?: T +) => [T, Dispatch>] = ( + key: string, + initialValue?: T +) => useStorage('localStorage', key, initialValue) + +export const useSessionStorage: ( + key: string, + initialValue?: T +) => [T, Dispatch>] = ( + key: string, + initialValue?: T +) => useStorage('sessionStorage', key, initialValue) diff --git a/src-ts/lib/member-autocomplete/InputHandleAutocomplete.module.scss b/src-ts/lib/member-autocomplete/InputHandleAutocomplete.module.scss new file mode 100644 index 000000000..3ffa6b159 --- /dev/null +++ b/src-ts/lib/member-autocomplete/InputHandleAutocomplete.module.scss @@ -0,0 +1,5 @@ +@import "../styles/variables/palette"; + +.memberSelect { + color: $black-60; +} diff --git a/src-ts/lib/member-autocomplete/InputHandleAutocomplete.tsx b/src-ts/lib/member-autocomplete/InputHandleAutocomplete.tsx new file mode 100644 index 000000000..03a8810d5 --- /dev/null +++ b/src-ts/lib/member-autocomplete/InputHandleAutocomplete.tsx @@ -0,0 +1,94 @@ +import { FC, FocusEvent } from 'react' +import { MultiValue, StylesConfig } from 'react-select' +// tslint:disable-next-line: no-submodule-imports +import AsyncSelect from 'react-select/async' + +import { InputWrapper } from '../form/form-groups/form-input/input-wrapper' + +import { membersAutocompete, MembersAutocompeteResult } from './input-handle-functions' +import styles from './InputHandleAutocomplete.module.scss' + +export interface InputHandleAutocompleteProps { + readonly className?: string + readonly dirty?: boolean + readonly disabled?: boolean + readonly error?: string + readonly hideInlineErrors?: boolean + readonly hint?: string + readonly label?: string | JSX.Element + readonly name: string + readonly onBlur?: (event: FocusEvent) => void + readonly onChange: (newValue: Array) => void + readonly placeholder?: string + readonly tabIndex: number + readonly value?: Array +} + +const InputHandleAutocomplete: FC = (props: InputHandleAutocompleteProps) => { + const customStyles: StylesConfig = { + control: (provided) => ({ + ...provided, + border: 'none', + }), + input: (provided) => ({ + ...provided, + color: 'inherit', + fontSize: 16, + }), + multiValue: (provided) => ({ + ...provided, + borderRadius: 50, + }), + multiValueLabel: (provided) => ({ + ...provided, + fontSize: 12, + }), + option: (provided) => ({ + ...provided, + borderBottom: '1px solid #E9E9E9', + color: 'inherit', + fontSize: 16, + fontWeight: 400, + padding: 16, + }), + placeholder: (provided) => ({ + ...provided, + color: 'inherit', + fontSize: 16, + fontWeight: 400, + }), + valueContainer: (provided) => ({ + ...provided, + padding: 0, + }), + } + + return ( + + handle} + getOptionValue={({ userId }) => userId} + isMulti + key={props.value?.length} + loadOptions={membersAutocompete} + styles={customStyles} + placeholder={props.placeholder} + onBlur={props.onBlur} + onChange={(newValue: MultiValue) => props.onChange(newValue as Array)} + value={props.value} + isDisabled={props.disabled} + /> + + ) +} + +export default InputHandleAutocomplete diff --git a/src-ts/lib/member-autocomplete/index.ts b/src-ts/lib/member-autocomplete/index.ts new file mode 100644 index 000000000..92b750f67 --- /dev/null +++ b/src-ts/lib/member-autocomplete/index.ts @@ -0,0 +1 @@ +export { default as InputHandleAutocomplete } from './InputHandleAutocomplete' diff --git a/src-ts/lib/member-autocomplete/input-handle-functions.ts b/src-ts/lib/member-autocomplete/input-handle-functions.ts new file mode 100644 index 000000000..7fbd66afa --- /dev/null +++ b/src-ts/lib/member-autocomplete/input-handle-functions.ts @@ -0,0 +1,23 @@ +import qs from 'qs' + +import { xhrGetAsync } from '..' +import { EnvironmentConfig } from '../../config' + +export interface MembersAutocompeteQuery { + term: string +} + +export interface MembersAutocompeteResult { + firstName: string + handle: string + lastName: string + userId: string +} + +export async function membersAutocompete(term: string): Promise> { + const query: MembersAutocompeteQuery = { + term, + } + + return xhrGetAsync(`${EnvironmentConfig.API.V5}/members/autocomplete?${qs.stringify(query)}`) +} diff --git a/src-ts/lib/pagination/index.ts b/src-ts/lib/pagination/index.ts index 3b90b0623..b8e69f71a 100644 --- a/src-ts/lib/pagination/index.ts +++ b/src-ts/lib/pagination/index.ts @@ -1,2 +1,5 @@ +export * from './infinite-page-dao.model' +export * from './infinite-page-handler.model' export * from './page.model' export * from './sort.model' +export * from './use-infinite-page.hook' diff --git a/src-ts/lib/pagination/infinite-page-dao.model.ts b/src-ts/lib/pagination/infinite-page-dao.model.ts new file mode 100644 index 000000000..ae1ca01da --- /dev/null +++ b/src-ts/lib/pagination/infinite-page-dao.model.ts @@ -0,0 +1,5 @@ +export interface InfinitePageDao { + count: number + // TODO: rename this 'items' so it can be used in a grid/card view + rows: ReadonlyArray +} diff --git a/src-ts/lib/pagination/infinite-page-handler.model.ts b/src-ts/lib/pagination/infinite-page-handler.model.ts new file mode 100644 index 000000000..cab09cc1a --- /dev/null +++ b/src-ts/lib/pagination/infinite-page-handler.model.ts @@ -0,0 +1,5 @@ +export interface InfinitePageHandler { + data?: ReadonlyArray + getAndSetNext: () => void + hasMore: boolean +} diff --git a/src-ts/lib/pagination/use-infinite-page.hook.ts b/src-ts/lib/pagination/use-infinite-page.hook.ts new file mode 100644 index 000000000..6f1367ea2 --- /dev/null +++ b/src-ts/lib/pagination/use-infinite-page.hook.ts @@ -0,0 +1,25 @@ +import { flatten, map } from 'lodash' +// tslint:disable-next-line: no-submodule-imports +import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite' + +import { InfinitePageDao } from './infinite-page-dao.model' +import { InfinitePageHandler } from './infinite-page-handler.model' + +export function useGetInfinitePage(getKey: (index: number, previousPageData: InfinitePageDao) => string | undefined): + InfinitePageHandler { + + const { data, setSize, size }: SWRInfiniteResponse> = useSWRInfinite(getKey, { revalidateFirstPage: false }) + + // flatten version of badges paginated data + const outputData: ReadonlyArray = flatten(map(data, dao => dao.rows)) + + function getAndSetNext(): void { + setSize(size + 1) + } + + return { + data: outputData, + getAndSetNext, + hasMore: outputData.length < (data?.[0]?.count || 0), + } +} diff --git a/src-ts/lib/styles/mixins/_icons.mixins.scss b/src-ts/lib/styles/mixins/_icons.mixins.scss index 30080f592..bebd7d98d 100644 --- a/src-ts/lib/styles/mixins/_icons.mixins.scss +++ b/src-ts/lib/styles/mixins/_icons.mixins.scss @@ -40,6 +40,11 @@ width: $space-mxx; } +@mixin icon-full { + height: 100%; + width: 100%; +} + @mixin icon-size($size) { height: $size * 1px; width: $size * 1px; diff --git a/src-ts/lib/styles/mixins/_layout.mixins.scss b/src-ts/lib/styles/mixins/_layout.mixins.scss index ac5146af1..38d79f5e6 100644 --- a/src-ts/lib/styles/mixins/_layout.mixins.scss +++ b/src-ts/lib/styles/mixins/_layout.mixins.scss @@ -8,7 +8,7 @@ padding-left: $space-xxl; padding-right: $space-xxl; } - + @include xxs { padding-left: $space-lg; padding-right: $space-lg; @@ -23,20 +23,27 @@ } @mixin scrollbar { + // firefox's solution for "customizing" scrollbars + & { + scrollbar-width: thin; + scrollbar-color: rgba($tc-black, 0.4) transparent; + } + &::-webkit-scrollbar-track { background: transparent; } + &::-webkit-scrollbar { width: 5px; height: 5px; } - + &::-webkit-scrollbar-thumb { background-color: rgba($tc-black, 0.4); border-radius: 4px; - + &:hover { background-color: rgba($tc-black, 0.6); } } -} \ No newline at end of file +} diff --git a/src-ts/lib/table/Table.module.scss b/src-ts/lib/table/Table.module.scss index eeb951071..5582e4c7c 100644 --- a/src-ts/lib/table/Table.module.scss +++ b/src-ts/lib/table/Table.module.scss @@ -61,6 +61,10 @@ margin-right: -29px; } } + + &.centerHeader { + justify-content: center; + } } .tooltip { @@ -90,3 +94,8 @@ .tootlipBody { min-width: 200px; } + +.loadBtnWrap { + display: flex; + justify-content: center; +} \ No newline at end of file diff --git a/src-ts/lib/table/Table.tsx b/src-ts/lib/table/Table.tsx index 25c439700..c9fd0a2f7 100644 --- a/src-ts/lib/table/Table.tsx +++ b/src-ts/lib/table/Table.tsx @@ -1,6 +1,7 @@ import classNames from 'classnames' import { Dispatch, MouseEvent, SetStateAction, useEffect, useState } from 'react' +import { Button } from '../button' import { Sort } from '../pagination' import '../styles/_includes.scss' import { IconOutline } from '../svgs' @@ -15,7 +16,10 @@ import styles from './Table.module.scss' interface TableProps { readonly columns: ReadonlyArray> readonly data: ReadonlyArray + readonly moreToLoad?: boolean + readonly onLoadMoreClick?: () => void readonly onRowClick?: (data: T) => void + readonly onToggleSort?: (sort: Sort) => void } interface DefaultSortDirectionMap { @@ -34,7 +38,7 @@ const Table: (props: TableProps) = Dispatch> ] = useState() - const [sortedData, setSortData]: [ReadonlyArray, Dispatch>>] + const [sortedData, setSortedData]: [ReadonlyArray, Dispatch>>] = useState>(props.data) useEffect(() => { @@ -47,12 +51,17 @@ const Table: (props: TableProps) = setDefaultSortDirectionMap(map) } - setSortData(tableGetSorted(data, columns, sort)) + // if we have a sort handler, don't worry about getting the sorted data; + // otherwise, get the sorted data for the table + const sorted: ReadonlyArray = !!props.onToggleSort ? data : tableGetSorted(data, columns, sort) + + setSortedData(sorted) }, [ columns, data, defaultSortDirectionMap, + props.onToggleSort, sort, ]) @@ -75,6 +84,9 @@ const Table: (props: TableProps) = fieldName, } setSort(newSort) + + // call the callback to notify parent for sort update + props.onToggleSort?.(newSort) } const headerRow: Array = props.columns @@ -136,7 +148,7 @@ const Table: (props: TableProps) = // return the entire row return ( @@ -158,6 +170,18 @@ const Table: (props: TableProps) = {rowCells} + { + !!props.moreToLoad && !!props.onLoadMoreClick && ( +
+
+ ) + }
) } diff --git a/src-ts/lib/table/index.ts b/src-ts/lib/table/index.ts index d7b124c44..396d852ea 100644 --- a/src-ts/lib/table/index.ts +++ b/src-ts/lib/table/index.ts @@ -1,2 +1,3 @@ export * from './table-column.model' +export { tableGetDefaultSort } from './table-functions' export { default as Table } from './Table' diff --git a/src-ts/lib/table/table-functions/table.functions.ts b/src-ts/lib/table/table-functions/table.functions.ts index cfd4d25af..72e050f05 100644 --- a/src-ts/lib/table/table-functions/table.functions.ts +++ b/src-ts/lib/table/table-functions/table.functions.ts @@ -1,17 +1,21 @@ import { Sort } from '../../pagination' import { TableColumn } from '../table-column.model' -export function getDefaultSort(columns: ReadonlyArray>): Sort | undefined { +export function getDefaultSort(columns: ReadonlyArray>): Sort { const defaultSortColumn: TableColumn | undefined = columns.find(col => col.isDefaultSort) || columns.find(col => !!col.propertyName) + || columns?.[0] - const defaultSort: Sort | undefined = !defaultSortColumn?.propertyName - ? undefined - : { - direction: defaultSortColumn.defaultSortDirection || 'asc', - fieldName: defaultSortColumn.propertyName, - } + // if we didn't find a default sort, we have a problem + if (!defaultSortColumn) { + throw new Error('A table must have at least one column.') + } + + const defaultSort: Sort = { + direction: defaultSortColumn.defaultSortDirection || 'asc', + fieldName: defaultSortColumn.propertyName || '', + } return defaultSort } diff --git a/src-ts/tools/gamification-admin/GamificationAdmin.tsx b/src-ts/tools/gamification-admin/GamificationAdmin.tsx index 860b2c3ae..3d85c3598 100644 --- a/src-ts/tools/gamification-admin/GamificationAdmin.tsx +++ b/src-ts/tools/gamification-admin/GamificationAdmin.tsx @@ -1,9 +1,11 @@ import { FC, useContext } from 'react' import { Outlet, Routes } from 'react-router-dom' +import { SWRConfig } from 'swr' import { routeContext, RouteContextData, + xhrGetAsync, } from '../../lib' export const toolTitle: string = 'Gamification Admin' @@ -13,12 +15,17 @@ const GamificationAdmin: FC<{}> = () => { const { getChildRoutes }: RouteContextData = useContext(routeContext) return ( - <> + xhrGetAsync(resource), + refreshInterval: 60000, // 1 min + }} + > {getChildRoutes(toolTitle)} - + ) } diff --git a/src-ts/tools/gamification-admin/game-config/gamification-config.model.ts b/src-ts/tools/gamification-admin/game-config/gamification-config.model.ts new file mode 100644 index 000000000..0024cbf16 --- /dev/null +++ b/src-ts/tools/gamification-admin/game-config/gamification-config.model.ts @@ -0,0 +1,7 @@ +export interface GamificationConfigModel { + ACCEPTED_BADGE_MIME_TYPES: string + CSV_HEADER: Array, + MAX_BADGE_IMAGE_FILE_SIZE: number + ORG_ID: string + PAGE_SIZE: number +} diff --git a/src-ts/tools/gamification-admin/game-config/gamification.config.ts b/src-ts/tools/gamification-admin/game-config/gamification.config.ts new file mode 100644 index 000000000..e204a4040 --- /dev/null +++ b/src-ts/tools/gamification-admin/game-config/gamification.config.ts @@ -0,0 +1,27 @@ +import { EnvironmentConfig } from '../../../config' + +import { GamificationConfigModel } from './gamification-config.model' +import { GamificationConfigDefault } from './gamification.default.config' +import { GamificationConfigDev } from './gamification.dev.config' +import { GamificationConfigProd } from './gamification.prod.config' + +function getConfig(): GamificationConfigModel { + + switch (EnvironmentConfig.ENV) { + + case 'dev': + return GamificationConfigDev + + case 'prod': + return GamificationConfigProd + + default: + return GamificationConfigDefault + } +} + +const GamificationConfig: GamificationConfigModel = { + ...getConfig(), +} + +export default GamificationConfig diff --git a/src-ts/tools/gamification-admin/game-config/gamification.default.config.ts b/src-ts/tools/gamification-admin/game-config/gamification.default.config.ts new file mode 100644 index 000000000..05a4f7b51 --- /dev/null +++ b/src-ts/tools/gamification-admin/game-config/gamification.default.config.ts @@ -0,0 +1,9 @@ +import { GamificationConfigModel } from './gamification-config.model' + +export const GamificationConfigDefault: GamificationConfigModel = { + ACCEPTED_BADGE_MIME_TYPES: 'image/svg+xml,image/svg', + CSV_HEADER: ['tc_handle', 'badge_id'], + MAX_BADGE_IMAGE_FILE_SIZE: 5000000, // 5mb in bytes + ORG_ID: '6052dd9b-ea80-494b-b258-edd1331e27a3', + PAGE_SIZE: 12, +} diff --git a/src-ts/tools/gamification-admin/game-config/gamification.dev.config.ts b/src-ts/tools/gamification-admin/game-config/gamification.dev.config.ts new file mode 100644 index 000000000..02fec4630 --- /dev/null +++ b/src-ts/tools/gamification-admin/game-config/gamification.dev.config.ts @@ -0,0 +1,6 @@ +import { GamificationConfigModel } from './gamification-config.model' +import { GamificationConfigDefault } from './gamification.default.config' + +export const GamificationConfigDev: GamificationConfigModel = { + ...GamificationConfigDefault, +} diff --git a/src-ts/tools/gamification-admin/game-config/gamification.prod.config.ts b/src-ts/tools/gamification-admin/game-config/gamification.prod.config.ts new file mode 100644 index 000000000..e49185be0 --- /dev/null +++ b/src-ts/tools/gamification-admin/game-config/gamification.prod.config.ts @@ -0,0 +1,7 @@ +import { GamificationConfigModel } from './gamification-config.model' +import { GamificationConfigDefault } from './gamification.default.config' + +export const GamificationConfigProd: GamificationConfigModel = { + ...GamificationConfigDefault, + ORG_ID: 'e111f8df-6ac8-44d1-b4da-bb916f5e3425', +} diff --git a/src-ts/tools/gamification-admin/game-config/index.ts b/src-ts/tools/gamification-admin/game-config/index.ts new file mode 100644 index 000000000..60ed70fc0 --- /dev/null +++ b/src-ts/tools/gamification-admin/game-config/index.ts @@ -0,0 +1 @@ +export { default as GamificationConfig } from './gamification.config' diff --git a/src-ts/tools/gamification-admin/game-lib/game-badge.model.ts b/src-ts/tools/gamification-admin/game-lib/game-badge.model.ts new file mode 100644 index 000000000..f4de99dd3 --- /dev/null +++ b/src-ts/tools/gamification-admin/game-lib/game-badge.model.ts @@ -0,0 +1,16 @@ +// TODO: add factory to convert snake case property names to camel case +export interface GameBadge { + active: boolean + badge_description: string + badge_image_url: string + badge_name: string + badge_status: string + id: string + member_badges?: Array<{ + awarded_at: string, + awarded_by: string, + user_handle: string, + user_id: string, + }> + organization_id: string +} diff --git a/src-ts/tools/gamification-admin/game-lib/index.ts b/src-ts/tools/gamification-admin/game-lib/index.ts new file mode 100644 index 000000000..9c270733b --- /dev/null +++ b/src-ts/tools/gamification-admin/game-lib/index.ts @@ -0,0 +1,4 @@ +export * from './game-badge.model' +export * from './use-get-game-badges-page.hook' +export * from './use-gamification-breadcrumb.hook' +export * from './use-get-game-badge-details.hook' diff --git a/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/BadgeAssignedModal.module.scss b/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/BadgeAssignedModal.module.scss new file mode 100644 index 000000000..abd8dc340 --- /dev/null +++ b/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/BadgeAssignedModal.module.scss @@ -0,0 +1,54 @@ +@import "../../../../../lib/styles/variables"; +@import "../../../../../lib/styles/includes"; + +.wrapper { + display: flex; + flex-direction: column; + justify-content: space-between; + + .badge { + display: flex; + align-items: center; + margin-bottom: $space-xxl; + + @include ltemd { + margin-bottom: 0; + } + + .badge-image { + width: 43px; + height: 43px; + margin-right: $space-xl; + } + + .badge-image-disabled { + width: 43px; + height: 43px; + margin-right: $space-xl; + opacity: 0.5; + filter: grayscale(1); + } + + .badge-name { + font-size: 16px; + } + } + + .actions-wrap { + display: flex; + flex-direction: column; + + .actions { + display: flex; + align-items: center; + + @include ltemd { + justify-content: flex-end; + } + + a { + margin-right: $space-md; + } + } + } +} \ No newline at end of file diff --git a/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/BadgeAssignedModal.tsx b/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/BadgeAssignedModal.tsx new file mode 100644 index 000000000..5cce15d7f --- /dev/null +++ b/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/BadgeAssignedModal.tsx @@ -0,0 +1,55 @@ +import { FC } from 'react' + +import { BaseModal, Button, PageDivider, useCheckIsMobile } from '../../../../../lib' +import { GameBadge } from '../../game-badge.model' + +import styles from './BadgeAssignedModal.module.scss' +export interface BadgeAssignedModalProps { + badge: GameBadge + isOpen: boolean + onClose: () => void +} + +const BadgeAssignedModal: FC = (props: BadgeAssignedModalProps) => { + + const isMobile: boolean = useCheckIsMobile() + + function onClose(): void { + props.onClose() + } + + return ( + +
+
+ {props.badge.badge_name} +

{props.badge.badge_name} badge has been sucessfully awarded.

+
+
+ { + isMobile && + } +
+
+
+
+
+ ) +} + +export default BadgeAssignedModal diff --git a/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/index.ts b/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/index.ts new file mode 100644 index 000000000..7f2b04a0b --- /dev/null +++ b/src-ts/tools/gamification-admin/game-lib/modals/badge-assigned-modal/index.ts @@ -0,0 +1 @@ +export { default as BadgeAssignedModal } from './BadgeAssignedModal' diff --git a/src-ts/tools/gamification-admin/game-lib/modals/badge-created-modal/BadgeCreatedModal.module.scss b/src-ts/tools/gamification-admin/game-lib/modals/badge-created-modal/BadgeCreatedModal.module.scss new file mode 100644 index 000000000..abd8dc340 --- /dev/null +++ b/src-ts/tools/gamification-admin/game-lib/modals/badge-created-modal/BadgeCreatedModal.module.scss @@ -0,0 +1,54 @@ +@import "../../../../../lib/styles/variables"; +@import "../../../../../lib/styles/includes"; + +.wrapper { + display: flex; + flex-direction: column; + justify-content: space-between; + + .badge { + display: flex; + align-items: center; + margin-bottom: $space-xxl; + + @include ltemd { + margin-bottom: 0; + } + + .badge-image { + width: 43px; + height: 43px; + margin-right: $space-xl; + } + + .badge-image-disabled { + width: 43px; + height: 43px; + margin-right: $space-xl; + opacity: 0.5; + filter: grayscale(1); + } + + .badge-name { + font-size: 16px; + } + } + + .actions-wrap { + display: flex; + flex-direction: column; + + .actions { + display: flex; + align-items: center; + + @include ltemd { + justify-content: flex-end; + } + + a { + margin-right: $space-md; + } + } + } +} \ No newline at end of file diff --git a/src-ts/tools/gamification-admin/game-lib/modals/badge-created-modal/BadgeCreatedModal.tsx b/src-ts/tools/gamification-admin/game-lib/modals/badge-created-modal/BadgeCreatedModal.tsx new file mode 100644 index 000000000..d9009375a --- /dev/null +++ b/src-ts/tools/gamification-admin/game-lib/modals/badge-created-modal/BadgeCreatedModal.tsx @@ -0,0 +1,61 @@ +import { FC } from 'react' + +import { BaseModal, Button, PageDivider, useCheckIsMobile } from '../../../../../lib' +import { badgeDetailPath } from '../../../gamification-admin.routes' +import { GameBadge } from '../../game-badge.model' + +import styles from './BadgeCreatedModal.module.scss' +export interface BadgeCreatedModalProps { + badge: GameBadge + isOpen: boolean + onClose: () => void +} + +const BadgeCreatedModal: FC = (props: BadgeCreatedModalProps) => { + + const isMobile: boolean = useCheckIsMobile() + + function onClose(): void { + props.onClose() + } + + return ( + +
+
+ {props.badge.badge_name} +

{props.badge.badge_name} badge has been sucessfully created.

+
+
+ { + isMobile && + } +
+
+
+
+
+ ) +} + +export default BadgeCreatedModal diff --git a/src-ts/tools/gamification-admin/game-lib/modals/badge-created-modal/index.ts b/src-ts/tools/gamification-admin/game-lib/modals/badge-created-modal/index.ts new file mode 100644 index 000000000..2e8ce2cda --- /dev/null +++ b/src-ts/tools/gamification-admin/game-lib/modals/badge-created-modal/index.ts @@ -0,0 +1 @@ +export { default as BadgeCreatedModal } from './BadgeCreatedModal' diff --git a/src-ts/tools/gamification-admin/game-lib/use-gamification-breadcrumb.hook.tsx b/src-ts/tools/gamification-admin/game-lib/use-gamification-breadcrumb.hook.tsx new file mode 100644 index 000000000..274022b16 --- /dev/null +++ b/src-ts/tools/gamification-admin/game-lib/use-gamification-breadcrumb.hook.tsx @@ -0,0 +1,16 @@ +import { BreadcrumbItemModel } from '../../../lib' +import { basePath } from '../gamification-admin.routes' +import { toolTitle } from '../GamificationAdmin' + +export function useGamificationBreadcrumb(items: Array): Array { + + const breadcrumb: Array = [ + { + name: toolTitle, + url: basePath, + }, + ...items, + ] + + return breadcrumb +} diff --git a/src-ts/tools/gamification-admin/game-lib/use-get-game-badge-details.hook.ts b/src-ts/tools/gamification-admin/game-lib/use-get-game-badge-details.hook.ts new file mode 100644 index 000000000..349cf1efa --- /dev/null +++ b/src-ts/tools/gamification-admin/game-lib/use-get-game-badge-details.hook.ts @@ -0,0 +1,24 @@ +import useSWR, { KeyedMutator, SWRResponse } from 'swr' + +import { EnvironmentConfig } from '../../../config' + +import { GameBadge } from './game-badge.model' + +export interface BadgeDetailPageHandler { + data?: Readonly + error?: Readonly + mutate: KeyedMutator +} + +export function useGetGameBadgeDetails(badgeID: string): BadgeDetailPageHandler { + + const badgeEndpointUrl: URL = new URL(`${EnvironmentConfig.API.V5}/gamification/badges/${badgeID}`) + + const { data, error, mutate }: SWRResponse = useSWR(badgeEndpointUrl.toString()) + + return { + data, + error, + mutate, + } +} diff --git a/src-ts/tools/gamification-admin/game-lib/use-get-game-badges-page.hook.ts b/src-ts/tools/gamification-admin/game-lib/use-get-game-badges-page.hook.ts new file mode 100644 index 000000000..885d1af51 --- /dev/null +++ b/src-ts/tools/gamification-admin/game-lib/use-get-game-badges-page.hook.ts @@ -0,0 +1,30 @@ +import { EnvironmentConfig } from '../../../config' +import { InfinitePageDao, InfinitePageHandler, Sort, useGetInfinitePage } from '../../../lib' +import { GamificationConfig } from '../game-config' + +import { GameBadge } from './game-badge.model' + +export function useGetGameBadgesPage(sort: Sort): InfinitePageHandler { + + function getKey(index: number, previousPageData: InfinitePageDao): string | undefined { + + // reached the end + if (!!previousPageData && !previousPageData.rows.length) { + return undefined + } + + const params: Record = { + limit: `${GamificationConfig.PAGE_SIZE}`, + offset: `${index * GamificationConfig.PAGE_SIZE}`, + order_by: sort.fieldName, + order_type: sort.direction, + organization_id: GamificationConfig.ORG_ID, + } + + const badgeEndpointUrl: URL = new URL(`${EnvironmentConfig.API.V5}/gamification/badges?${new URLSearchParams(params)}`) + + return badgeEndpointUrl.toString() + } + + return useGetInfinitePage(getKey) +} diff --git a/src-ts/tools/gamification-admin/gamification-admin.routes.tsx b/src-ts/tools/gamification-admin/gamification-admin.routes.tsx index 5b8a60acd..ee01f39d4 100644 --- a/src-ts/tools/gamification-admin/gamification-admin.routes.tsx +++ b/src-ts/tools/gamification-admin/gamification-admin.routes.tsx @@ -5,8 +5,16 @@ import BadgeDetailPage from './pages/badge-detail/BadgeDetailPage' import BadgeListingPage from './pages/badge-listing/BadgeListingPage' import CreateBadgePage from './pages/create-badge/CreateBadgePage' -export const baseUrl: string = '/gamification-admin' -export const rolesRequired: Array = [UserRole.gamificationAdmin] +export const baseDetailPath: string = '/badge-detail' +export const createBadgePath: string = '/create-badge' + +export const basePath: string = '/gamification-admin' + +export function badgeDetailPath(badgeId: string, view?: 'edit' | 'award'): string { + return `${basePath}${baseDetailPath}/${badgeId}${!!view ? `#${view}` : ''}` +} + +export const createBadgeRoute: string = `${basePath}${createBadgePath}` export const gamificationAdminRoutes: Array = [ { @@ -18,17 +26,19 @@ export const gamificationAdminRoutes: Array = [ }, { element: , - route: '/create-badge', + route: createBadgePath, }, { element: , - route: '/badge-detail', + route: `${baseDetailPath}/:id`, }, ], element: , hidden: true, - rolesRequired, - route: baseUrl, + rolesRequired: [ + UserRole.gamificationAdmin, + ], + route: basePath, title: toolTitle, }, ] diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/AwardedMembersTab.module.scss b/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/AwardedMembersTab.module.scss new file mode 100644 index 000000000..36bd71e18 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/AwardedMembersTab.module.scss @@ -0,0 +1,3 @@ +.tabWrap { + display: flex; +} \ No newline at end of file diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/AwardedMembersTab.tsx b/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/AwardedMembersTab.tsx new file mode 100644 index 000000000..eff8fe2ff --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/AwardedMembersTab.tsx @@ -0,0 +1,19 @@ +import { FC } from 'react' + +import { GameBadge } from '../../../game-lib' + +import styles from './AwardedMembersTab.module.scss' + +export interface AwardedMembersTabProps { + badge: GameBadge +} + +const AwardedMembersTab: FC = (props: AwardedMembersTabProps) => { + return ( +
+ +
+ ) +} + +export default AwardedMembersTab diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/index.ts b/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/index.ts new file mode 100644 index 000000000..e9063473a --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/AwardedMembersTab/index.ts @@ -0,0 +1 @@ +export * from './AwardedMembersTab' diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.module.scss b/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.module.scss index 7da13b030..01e18d2e6 100644 --- a/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.module.scss +++ b/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.module.scss @@ -1,17 +1,142 @@ -.contentLayout { - width: 100%; - padding-bottom: 0; +@import "../../../../lib/styles/variables"; +@import "../../../../lib/styles/includes"; - .contentLayout-outer { - width: 100%; +$badgePreview: 130px; +$badgePreviewImage: 72px; - .contentLayout-inner { - width: 100%; - overflow: visible; +.container { + display: flex; + flex-direction: column; + + .error { + background-color: $black-5; + color: red; + padding: $space-sm; } - } -} -.container { - display: flex; -} + .badge { + display: flex; + margin-top: $space-xxxxl; + + @include ltemd { + flex-direction: column; + } + + .badgeImage { + position: relative; + border-radius: $space-sm; + background-color: $black-5; + margin-right: $space-xxxxl; + display: flex; + align-items: center; + justify-content: center; + width: $badgePreview; + height: $badgePreview; + min-width: $badgePreview; + min-height: $badgePreview; + + @include ltemd { + margin-right: 0; + margin-bottom: $space-xxl; + width: 100%; + } + + img { + width: $badgePreviewImage; + height: $badgePreviewImage; + } + + .filePickerPencil { + position: absolute; + top: 0; + right: 0; + color: $turq-160; + } + + .filePickerInput { + display: none; + } + } + + .badgeDetails { + display: flex; + flex-direction: column; + flex: 1; + + .badgeName { + font-family: $font-roboto; + font-weight: $font-weight-bold; + padding: $space-sm; + font-size: 24px; + line-height: 32px; + + @include ltemd { + font-size: 20px; + line-height: 28px; + } + + &:hover { + background-color: $black-5; + cursor: text; + } + + &:focus { + background-color: $tc-white; + outline-color: $turq-160; + } + } + + .badgeDesc { + margin-top: $space-sm; + + .badgeEditWrap { + display: flex; + flex-direction: column; + + .badgeEditable, + .badgeEditableMode { + padding: $space-sm; + border-radius: 3px; + border: 2px solid $tc-white; + + &:hover { + background-color: $black-5; + cursor: text; + } + + &:focus { + background-color: $tc-white; + outline: none; + } + + a { + color: $link-blue-dark; + } + } + + .badgeEditableMode { + border: 2px solid $turq-160; + } + + .badgeEditActions { + display: flex; + justify-content: flex-end; + margin-top: $space-sm; + + button:first-child { + margin-right: $space-sm; + } + } + } + } + } + } + + .activeTabElement { + margin-top: $space-xxxxl; + + @include ltemd { + margin-top: $space-md; + } + } +} \ No newline at end of file diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.tsx b/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.tsx index 4923e6c95..1b092176d 100644 --- a/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.tsx +++ b/src-ts/tools/gamification-admin/pages/badge-detail/BadgeDetailPage.tsx @@ -1,30 +1,297 @@ -import { FC, useMemo } from 'react' +import { noop, trim } from 'lodash' +import MarkdownIt from 'markdown-it' +import { createRef, Dispatch, FC, KeyboardEvent, RefObject, SetStateAction, useEffect, useState } from 'react' +import ContentEditable from 'react-contenteditable' +import { Params, useLocation, useParams } from 'react-router-dom' +import { toast } from 'react-toastify' -import { Breadcrumb, BreadcrumbItemModel, ContentLayout } from '../../../../lib' -import { baseUrl } from '../../gamification-admin.routes' -import { toolTitle } from '../../GamificationAdmin' +import { Breadcrumb, BreadcrumbItemModel, Button, ButtonProps, ContentLayout, IconOutline, LoadingSpinner, PageDivider, TabsNavbar, TabsNavItem } from '../../../../lib' +import { GamificationConfig } from '../../game-config' +import { BadgeDetailPageHandler, GameBadge, useGamificationBreadcrumb, useGetGameBadgeDetails } from '../../game-lib' +import AwardedMembersTab from './AwardedMembersTab/AwardedMembersTab' +import { badgeDetailsTabs, BadgeDetailsTabViews } from './badge-details-tabs.config' +import { submitRequestAsync as updateBadgeAsync } from './badge-details.functions' import styles from './BadgeDetailPage.module.scss' +import BatchAwardTab from './BatchAwardTab/BatchAwardTab' +import ManualAwardTab from './ManualAwardTab/ManualAwardTab' +const md: MarkdownIt = new MarkdownIt({ + html: true, + // TODO: check with PM ig those are needed? + // linkify: true, + // typographer: true, +}) + +/* tslint:disable:cyclomatic-complexity */ const BadgeDetailPage: FC = () => { - const breadcrumb: Array = useMemo(() => [ - { name: toolTitle, url: baseUrl }, - { name: 'badge detail', url: '#' }, - ], []) - - return ( - - -
- -
-
- ) + const [headerButtonConfig, setHeaderButtonConfig]: [ + ButtonProps | undefined, + Dispatch>, + ] + = useState() + + const breadcrumb: Array = useGamificationBreadcrumb([ + { + name: 'badge detail', + url: '#', + }, + ]) + + const { id: badgeID }: Readonly> = useParams() + + const { hash }: { hash: string } = useLocation() + + const [activeTab, setActiveTab]: [string, Dispatch>] = useState( + hash === '#award' ? BadgeDetailsTabViews.manualAward : BadgeDetailsTabViews.awardedMembers + ) + + const [tabs]: [ + ReadonlyArray, + Dispatch>>, + ] + = useState>([...badgeDetailsTabs]) + + const badgeDetailsHandler: BadgeDetailPageHandler = useGetGameBadgeDetails(badgeID as string) + + const badgeNameRef: RefObject = createRef() + + const badgeDescRef: RefObject = createRef() + + const fileInputRef: RefObject = createRef() + + // tslint:disable-next-line:no-null-keyword + const [newImageFile, setNewImageFile]: [FileList | null, Dispatch>] = useState(null) + + const [fileDataURL, setFileDataURL]: [string | undefined, Dispatch>] = useState() + + const [isBadgeDescEditingMode, setIsBadgeDescEditingMode]: [boolean, Dispatch>] = useState(false) + + useEffect(() => { + if (newImageFile && newImageFile.length) { + const fileReader: FileReader = new FileReader() + fileReader.onload = e => { + const { result }: any = e.target + if (result) { + setFileDataURL(result) + } + } + fileReader.readAsDataURL(newImageFile[0]) + } else if (fileDataURL) { + setFileDataURL(undefined) + } + }, [ + newImageFile, + fileDataURL, + ]) + + useEffect(() => { + if (newImageFile && newImageFile.length) { + updateBadgeAsync({ + files: newImageFile as FileList, + id: badgeDetailsHandler.data?.id as string, + }) + .then((updatedBadge: GameBadge) => { + toast.success('Badge image file saved.') + badgeDetailsHandler.mutate({ + ...badgeDetailsHandler.data, + badge_image_url: updatedBadge.badge_image_url, + }) + }) + } + }, [ + newImageFile, + ]) + + useEffect(() => { + if (badgeDetailsHandler.data) { + switch (badgeDetailsHandler.data?.active) { + case true: + setHeaderButtonConfig({ + label: 'DeActivate', + onClick: onDisableBadge, + }) + break + case false: + setHeaderButtonConfig({ + label: 'Activate', + onClick: onActivateBadge, + }) + break + } + } + }, [ + badgeDetailsHandler.data, + ]) + + // define the tabs so they can be displayed on various tasks + const tabsElement: JSX.Element = ( + + ) + + function onActivateBadge(): void { + // TODO: implement in GAME-127 + } + + function onDisableBadge(): void { + // TODO: implement in GAME-127 + } + + function onNameEditKeyDown(e: KeyboardEvent): void { + if (e.key === 'Enter') { + e.preventDefault() + badgeNameRef.current?.blur() + } + } + + function onBadgeNameEditFocus(): void { + if (isBadgeDescEditingMode) { + setIsBadgeDescEditingMode(false) + } + } + + function onSaveBadgeName(): any { + const newBadgeName: string | undefined = trim(badgeNameRef.current?.innerHTML) + if (newBadgeName !== badgeDetailsHandler.data?.badge_name) { + // save only if different + updateBadgeAsync({ + badgeName: newBadgeName, + id: badgeDetailsHandler.data?.id as string, + }) + .then(() => { + toast.success('Badge name update saved.') + badgeDetailsHandler.mutate({ + ...badgeDetailsHandler.data, + badge_name: newBadgeName, + }) + }) + } + } + + function onSaveBadgeDesc(): any { + setIsBadgeDescEditingMode(false) + const newBadgeDesc: string | undefined = trim(badgeDescRef.current?.innerHTML) + if (newBadgeDesc !== badgeDetailsHandler.data?.badge_description) { + // save only if different + updateBadgeAsync({ + badgeDesc: newBadgeDesc, + id: badgeDetailsHandler.data?.id as string, + }) + .then(() => { + toast.success('Badge description update saved.') + badgeDetailsHandler.mutate({ + ...badgeDetailsHandler.data, + badge_description: newBadgeDesc, + }) + }) + } + } + + // default tab + let activeTabElement: JSX.Element + = + if (activeTab === BadgeDetailsTabViews.manualAward) { + activeTabElement = + } + if (activeTab === BadgeDetailsTabViews.batchAward) { + activeTabElement = + } + + // show page loader if we fetching results + if (!badgeDetailsHandler.data && !badgeDetailsHandler.error) { + return + } + + return ( + + +
+ { + badgeDetailsHandler.error ? ( +
+ {badgeDetailsHandler.error.message} +
+ ) : ( + <> +
+
+
+
+ +
+
+ setIsBadgeDescEditingMode(true)} + className={isBadgeDescEditingMode ? styles.badgeEditableMode : styles.badgeEditable} + /> + { + isBadgeDescEditingMode &&
+
+ } +
+
+
+
+ + {tabsElement} +
+ {activeTabElement} +
+ + ) + } +
+
+ ) } export default BadgeDetailPage diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/BatchAwardTab.module.scss b/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/BatchAwardTab.module.scss new file mode 100644 index 000000000..36bd71e18 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/BatchAwardTab.module.scss @@ -0,0 +1,3 @@ +.tabWrap { + display: flex; +} \ No newline at end of file diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/BatchAwardTab.tsx b/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/BatchAwardTab.tsx new file mode 100644 index 000000000..d71def501 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/BatchAwardTab.tsx @@ -0,0 +1,13 @@ +import { FC } from 'react' + +import styles from './BatchAwardTab.module.scss' + +const BatchAwardTab: FC = () => { + return ( +
+

Batch Award

+
+ ) +} + +export default BatchAwardTab diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/index.ts b/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/index.ts new file mode 100644 index 000000000..1cb3ea691 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/BatchAwardTab/index.ts @@ -0,0 +1 @@ +export * from './BatchAwardTab' diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/ManualAwardTab.module.scss b/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/ManualAwardTab.module.scss new file mode 100644 index 000000000..9e069b176 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/ManualAwardTab.module.scss @@ -0,0 +1,23 @@ +@import "../../../../../lib/styles/variables/palette"; +@import "../../../../../lib/styles/includes"; + +.tabWrap { + display: flex; + flex-direction: column; + + .manualFormWrap { + display: grid; + grid-template-columns: 1fr 1fr; + gap: $space-xxxxl; + margin-top: $space-xxl; + + @include ltemd { + grid-template-columns: 1fr; + } + + .manualForm { + display: flex; + flex-direction: column; + } + } +} \ No newline at end of file diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/ManualAwardTab.tsx b/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/ManualAwardTab.tsx new file mode 100644 index 000000000..284ad8f8d --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/ManualAwardTab.tsx @@ -0,0 +1,86 @@ +import { find } from 'lodash' +import { Dispatch, FC, SetStateAction, useState } from 'react' + +import { Button } from '../../../../../lib' +import { InputHandleAutocomplete } from '../../../../../lib/member-autocomplete' +import { MembersAutocompeteResult } from '../../../../../lib/member-autocomplete/input-handle-functions' +import { GameBadge } from '../../../game-lib' +import { BadgeAssignedModal } from '../../../game-lib/modals/badge-assigned-modal' +import { generateCSV, manualAssignRequestAsync } from '../badge-details.functions' + +import styles from './ManualAwardTab.module.scss' + +export interface ManualAwardTabProps { + badge: GameBadge +} + +const ManualAwardTab: FC = (props: ManualAwardTabProps) => { + + const [selectedMembers, setSelectedMembers]: [Array, Dispatch>>] + = useState>([]) + + const [showBadgeAssigned, setShowBadgeAssigned]: [boolean, Dispatch>] = useState(false) + + const [badgeAssignError, setBadgeAssignError]: [string | undefined, Dispatch>] = useState() + + function onAward(): void { + const csv: string = generateCSV( + selectedMembers.map(m => [m.handle, props.badge?.id as string]) + ) + setBadgeAssignError(undefined) + manualAssignRequestAsync(csv) + .then(() => { + setShowBadgeAssigned(true) + setSelectedMembers([]) + }) + .catch(e => { + let message: string = e.message + if (e.errors && e.errors[0] && e.errors[0].path === 'user_id') { + const handleOrId: string = find(selectedMembers, { userId: e.errors[0].value })?.handle || e.errors[0].value + message = `Member ${handleOrId} alredy owns this badge.` + } + setBadgeAssignError(message) + }) + } + + return ( +
+

Manual Award

+
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Neque ullamcorper neque sed orci, enim amet, sed.

+
+ +
+
+
+
+ { + showBadgeAssigned && { + setShowBadgeAssigned(false) + }} + /> + } +
+ ) +} + +export default ManualAwardTab diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/index.ts b/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/index.ts new file mode 100644 index 000000000..750a3f7a4 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/ManualAwardTab/index.ts @@ -0,0 +1 @@ +export * from './ManualAwardTab' diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/badge-details-tabs.config.ts b/src-ts/tools/gamification-admin/pages/badge-detail/badge-details-tabs.config.ts new file mode 100644 index 000000000..9c9e0ec72 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/badge-details-tabs.config.ts @@ -0,0 +1,22 @@ +import { TabsNavItem } from '../../../../lib' + +export enum BadgeDetailsTabViews { + awardedMembers = 'Awarded Members', + manualAward = 'Manual Award', + batchAward = 'Batch Award', +} + +export const badgeDetailsTabs: ReadonlyArray = [ + { + id: BadgeDetailsTabViews.awardedMembers, + title: 'Awarded Members', + }, + { + id: BadgeDetailsTabViews.manualAward, + title: 'Manual Award', + }, + { + id: BadgeDetailsTabViews.batchAward, + title: 'Batch Award', + }, +] diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/badge-details.functions.ts b/src-ts/tools/gamification-admin/pages/badge-detail/badge-details.functions.ts new file mode 100644 index 000000000..27b8ba585 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/badge-details.functions.ts @@ -0,0 +1,20 @@ +import { GamificationConfig } from '../../game-config' +import { GameBadge } from '../../game-lib' + +import { submitRequestAsync as submitBadgeAssingRequestAsync } from './manual-assign-badge.store' +import { submitRequestAsync as submitBadgeUpdateRequestAsync } from './update-badge.store' +import { UpdateBadgeRequest } from './updated-badge-request.model' + +export async function submitRequestAsync(request: UpdateBadgeRequest): Promise { + return submitBadgeUpdateRequestAsync(request) +} + +export function generateCSV(input: Array>): string { + input.unshift(GamificationConfig.CSV_HEADER) + + return input.map(row => row.join(',')).join('\n') +} + +export async function manualAssignRequestAsync(csv: string): Promise { + return submitBadgeAssingRequestAsync(csv) +} diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/manual-assign-badge.store.ts b/src-ts/tools/gamification-admin/pages/badge-detail/manual-assign-badge.store.ts new file mode 100644 index 000000000..ae0cbc545 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/manual-assign-badge.store.ts @@ -0,0 +1,13 @@ +import { EnvironmentConfig } from '../../../../config' +import { xhrPostAsync } from '../../../../lib' + +export async function submitRequestAsync(csv: string): Promise { + const url: string = `${EnvironmentConfig.API.V5}/gamification/badges/assign` + + const form: any = new FormData() + + // fill the form + form.append('file', new Blob([csv], { type: 'text/csv' }), 'data.csv') + + return xhrPostAsync(url, form) +} diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/update-badge.store.ts b/src-ts/tools/gamification-admin/pages/badge-detail/update-badge.store.ts new file mode 100644 index 000000000..15c934e4f --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/update-badge.store.ts @@ -0,0 +1,28 @@ +import { EnvironmentConfig } from '../../../../config' +import { xhrPostAsync } from '../../../../lib' +import { GameBadge } from '../../game-lib' + +import { UpdateBadgeRequest } from './updated-badge-request.model' + +export async function submitRequestAsync(request: UpdateBadgeRequest): Promise { + const url: string = `${EnvironmentConfig.API.V5}/gamification/badges/${request.id}` + + const form: any = new FormData() + + // fill the form, all fields optional + if (request.files) { + form.append('file', request.files[0]) + } + if (request.badgeActive !== undefined) { + form.append('active', request.badgeActive) + } + if (request.badgeName) { ( + form.append('badge_name', request.badgeName) + ) + } + if (request.badgeDesc) { + form.append('badge_description', request.badgeDesc) + } + + return xhrPostAsync(url, form) +} diff --git a/src-ts/tools/gamification-admin/pages/badge-detail/updated-badge-request.model.ts b/src-ts/tools/gamification-admin/pages/badge-detail/updated-badge-request.model.ts new file mode 100644 index 000000000..d4712174d --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-detail/updated-badge-request.model.ts @@ -0,0 +1,7 @@ +export interface UpdateBadgeRequest { + badgeActive?: boolean + badgeDesc?: string + badgeName?: string + files?: FileList + id: string +} diff --git a/src-ts/tools/gamification-admin/pages/badge-listing/BadgeListingPage.module.scss b/src-ts/tools/gamification-admin/pages/badge-listing/BadgeListingPage.module.scss index 7da13b030..36db3452d 100644 --- a/src-ts/tools/gamification-admin/pages/badge-listing/BadgeListingPage.module.scss +++ b/src-ts/tools/gamification-admin/pages/badge-listing/BadgeListingPage.module.scss @@ -1,17 +1,106 @@ -.contentLayout { - width: 100%; - padding-bottom: 0; +@import "../../../../lib/styles/includes"; +@import "../../../../lib/styles/variables"; - .contentLayout-outer { - width: 100%; +.container { + display: flex; + flex-direction: column; + padding-top: $space-xxxxl; + + .badges-table-header { + display: flex; + justify-content: space-between; + padding-bottom: $space-lg; + color: $black-60; + @include font-barlow; + @include font-weight-semibold; + font-size: 11px; + line-height: 11px; + + .col-sort { + display: flex; + margin-left: 94px; + align-items: center; + + svg { + color: $black-100; + } + } + + div:last-child { + margin-right: 100px; - .contentLayout-inner { - width: 100%; - overflow: visible; + @include ltemd { + display: none; + } } } -} -.container { - display: flex; + .badges-table { + display: flex; + flex-direction: column; + + .badge-row { + display: flex; + justify-content: space-between; + padding: $space-lg $space-xxl; + + @include ltemd { + flex-direction: column; + } + + &:nth-child(odd) { + background-color: $black-5; + border-radius: 8px; + } + + .badge { + display: flex; + align-items: center; + + .badge-image { + width: 48px; + height: 48px; + margin-right: $space-xl; + } + + .badge-image-disabled { + width: 48px; + height: 48px; + margin-right: $space-xl; + opacity: 0.5; + filter: grayscale(1); + } + + .badge-name { + font-size: 16px; + font-weight: 700; + line-height: 24px; + color: $black-100; + } + } + + .actions { + display: flex; + align-items: center; + + @include ltemd { + margin: $space-sm 0; + } + + .action-btn { + margin-right: $space-sm; + + &:last-child { + margin-right: 0; + } + } + } + } + } + + .loadbtn-wrap { + display: flex; + justify-content: center; + flex: 1; + } } diff --git a/src-ts/tools/gamification-admin/pages/badge-listing/BadgeListingPage.tsx b/src-ts/tools/gamification-admin/pages/badge-listing/BadgeListingPage.tsx index 04fca91ee..dfa788084 100644 --- a/src-ts/tools/gamification-admin/pages/badge-listing/BadgeListingPage.tsx +++ b/src-ts/tools/gamification-admin/pages/badge-listing/BadgeListingPage.tsx @@ -1,23 +1,64 @@ -import { FC } from 'react' +import { Dispatch, FC, SetStateAction, useState } from 'react' +import { NavigateFunction, useNavigate } from 'react-router-dom' -import { ContentLayout } from '../../../../lib' +import { + ButtonProps, + ContentLayout, + InfinitePageHandler, + LoadingSpinner, + Sort, + Table, + TableColumn, + tableGetDefaultSort +} from '../../../../lib' +import { GameBadge, useGetGameBadgesPage } from '../../game-lib' +import { createBadgeRoute } from '../../gamification-admin.routes' +import { badgeListingColumns } from './badge-listing-table' import styles from './BadgeListingPage.module.scss' const BadgeListingPage: FC = () => { - return ( - -
- -
-
- ) + const [sort, setSort]: [Sort, Dispatch>] = useState(tableGetDefaultSort(badgeListingColumns)) + const [columns]: [ + ReadonlyArray>, + Dispatch>>>, + ] + = useState>>([...badgeListingColumns]) + + const pageHandler: InfinitePageHandler = useGetGameBadgesPage(sort) + const navigate: NavigateFunction = useNavigate() + + function onSortClick(newSort: Sort): void { + setSort({ ...newSort }) + } + + // header button config + const buttonConfig: ButtonProps = { + label: 'Create New Badge', + onClick: () => navigate(createBadgeRoute), + } + + if (!pageHandler.data) { + return + } + + return ( + +
+ + + + ) } export default BadgeListingPage diff --git a/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-action-renderer/BadgeActionRenderer.module.scss b/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-action-renderer/BadgeActionRenderer.module.scss new file mode 100644 index 000000000..0ce6473f5 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-action-renderer/BadgeActionRenderer.module.scss @@ -0,0 +1,27 @@ +@import "../../../../../../lib/styles/includes"; +@import "../../../../../../lib/styles/variables"; + +.badge-actions { + display: flex; + align-items: center; + justify-content: center; + padding-top: $space-lg; + + @include ltemd { + flex-direction: column; + align-items: flex-end; + } + + a { + margin-right: $space-sm; + + @include ltemd { + margin-right: 0; + margin-bottom: $space-sm; + } + + &:last-child { + margin-right: 0; + } + } +} \ No newline at end of file diff --git a/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-action-renderer/BadgeActionRenderer.tsx b/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-action-renderer/BadgeActionRenderer.tsx new file mode 100644 index 000000000..6ca456669 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-action-renderer/BadgeActionRenderer.tsx @@ -0,0 +1,49 @@ +import { Button, ButtonProps, useCheckIsMobile } from '../../../../../../lib' +import { GameBadge } from '../../../../game-lib' +import { badgeDetailPath } from '../../../../gamification-admin.routes' + +import styles from './BadgeActionRenderer.module.scss' + +function BadgeActionRenderer(badge: GameBadge): JSX.Element { + + const isMobile: boolean = useCheckIsMobile() + + const buttonProps: ButtonProps = { + buttonStyle: 'secondary', + size: isMobile ? 'xs' : 'sm', + } + + const actionButtons: Array<{ + label: string + view?: 'edit' | 'award' + }> = [ + { + label: 'View', + }, + { + label: 'Edit', + view: 'edit', + }, + { + label: 'Award', + view: 'award', + }, + ] + + return ( +
+ {actionButtons.map((button, index) => { + return ( +
+ ) +} + +export default BadgeActionRenderer diff --git a/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-action-renderer/index.ts b/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-action-renderer/index.ts new file mode 100644 index 000000000..8324241f1 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-action-renderer/index.ts @@ -0,0 +1 @@ +export { default as BadgeActionRenderer } from './BadgeActionRenderer' diff --git a/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-listing-table.config.tsx b/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-listing-table.config.tsx new file mode 100644 index 000000000..1d6d54ef4 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-listing-table.config.tsx @@ -0,0 +1,20 @@ +import { TableColumn } from '../../../../../lib' +import { GameBadge } from '../../../game-lib' + +import { BadgeActionRenderer } from './badge-action-renderer' +import { BadgeListingNameRenderer } from './badge-name-renderer' + +export const badgeListingColumns: ReadonlyArray> = [ + { + defaultSortDirection: 'asc', + isDefaultSort: true, + label: 'Badge Name', + propertyName: 'badge_name', + renderer: BadgeListingNameRenderer, + type: 'element', + }, + { + renderer: BadgeActionRenderer, + type: 'action', + }, +] diff --git a/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-name-renderer/BadgeListingNameRenderer.module.scss b/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-name-renderer/BadgeListingNameRenderer.module.scss new file mode 100644 index 000000000..0bbb9a231 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-name-renderer/BadgeListingNameRenderer.module.scss @@ -0,0 +1,28 @@ +@import "../../../../../../lib/styles/includes"; +@import "../../../../../../lib/styles/variables"; + +.badge { + display: flex; + align-items: center; + + .badge-image { + width: 48px; + height: 48px; + margin-right: $space-xl; + } + + .badge-image-disabled { + width: 48px; + height: 48px; + margin-right: $space-xl; + opacity: 0.5; + filter: grayscale(1); + } + + .badge-name { + font-size: 16px; + font-weight: 700; + line-height: 24px; + color: $black-100; + } +} \ No newline at end of file diff --git a/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-name-renderer/BadgeListingNameRenderer.tsx b/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-name-renderer/BadgeListingNameRenderer.tsx new file mode 100644 index 000000000..7b9c604ca --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-name-renderer/BadgeListingNameRenderer.tsx @@ -0,0 +1,18 @@ +import { GameBadge } from '../../../../game-lib' + +import styles from './BadgeListingNameRenderer.module.scss' + +function BadgeListingNameRenderer(badge: GameBadge): JSX.Element { + return ( +
+ {badge.badge_name} +

{badge.badge_name}

+
+ ) +} + +export default BadgeListingNameRenderer diff --git a/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-name-renderer/index.ts b/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-name-renderer/index.ts new file mode 100644 index 000000000..dac5de575 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/badge-name-renderer/index.ts @@ -0,0 +1 @@ +export { default as BadgeListingNameRenderer } from './BadgeListingNameRenderer' diff --git a/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/index.ts b/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/index.ts new file mode 100644 index 000000000..8fa32ec69 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/badge-listing/badge-listing-table/index.ts @@ -0,0 +1 @@ +export * from './badge-listing-table.config' diff --git a/src-ts/tools/gamification-admin/pages/create-badge/CreateBadgePage.module.scss b/src-ts/tools/gamification-admin/pages/create-badge/CreateBadgePage.module.scss index 7da13b030..fac1cbcbc 100644 --- a/src-ts/tools/gamification-admin/pages/create-badge/CreateBadgePage.module.scss +++ b/src-ts/tools/gamification-admin/pages/create-badge/CreateBadgePage.module.scss @@ -1,17 +1,6 @@ -.contentLayout { - width: 100%; - padding-bottom: 0; - - .contentLayout-outer { - width: 100%; - - .contentLayout-inner { - width: 100%; - overflow: visible; - } - } -} +@import "../../../../lib/styles/variables"; .container { display: flex; + padding-top: $space-xxxxl; } diff --git a/src-ts/tools/gamification-admin/pages/create-badge/CreateBadgePage.tsx b/src-ts/tools/gamification-admin/pages/create-badge/CreateBadgePage.tsx index 020b7f8eb..e535b48e9 100644 --- a/src-ts/tools/gamification-admin/pages/create-badge/CreateBadgePage.tsx +++ b/src-ts/tools/gamification-admin/pages/create-badge/CreateBadgePage.tsx @@ -1,30 +1,55 @@ -import { FC, useMemo } from 'react' +import { Dispatch, FC, SetStateAction, useState } from 'react' import { Breadcrumb, BreadcrumbItemModel, ContentLayout } from '../../../../lib' -import { baseUrl } from '../../gamification-admin.routes' -import { toolTitle } from '../../GamificationAdmin' +import { GameBadge, useGamificationBreadcrumb } from '../../game-lib' +import { BadgeCreatedModal } from '../../game-lib/modals/badge-created-modal' +import { CreateBadgeForm, createBadgeFormDef } from './create-badge-form' import styles from './CreateBadgePage.module.scss' const CreateBadgePage: FC = () => { - const breadcrumb: Array = useMemo(() => [ - { name: toolTitle, url: baseUrl }, - { name: 'create badge', url: '#' }, - ], []) - - return ( - - -
- -
-
- ) + + const breadcrumb: Array = useGamificationBreadcrumb([ + { + name: 'create badge', + url: '#', + }, + ]) + + const [showBadgeCreatedModal, setShowBadgeCreatedModal]: [boolean, Dispatch>] + = useState(false) + + const [createdBadge, setCreatedBadge]: [GameBadge | undefined, Dispatch>] + = useState() + + function onSave(newBadge: GameBadge): void { + setCreatedBadge(newBadge) + setShowBadgeCreatedModal(true) + } + + return ( + + +
+ +
+ { + createdBadge && { + setCreatedBadge(undefined) + setShowBadgeCreatedModal(false) + }} + /> + } +
+ ) } export default CreateBadgePage diff --git a/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/CreateBadgeForm.tsx b/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/CreateBadgeForm.tsx new file mode 100644 index 000000000..ceae9d636 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/CreateBadgeForm.tsx @@ -0,0 +1,52 @@ +import { FC } from 'react' + +import { Form, FormDefinition, formGetInputModel, FormInputModel } from '../../../../../lib' +import { GamificationConfig } from '../../../game-config' +import { GameBadge } from '../../../game-lib' + +import { CreateBadgeFormField } from './create-badge-form.config' +import { CreateBadgeRequest } from './create-badge-functions' +import { createBadgeSubmitRequestAsync } from './create-badge-functions/create-badge-store' + +export interface CreateBadgeFormProps { + formDef: FormDefinition + onSave: (createdBadge: GameBadge) => void +} + +const CreateBadgeForm: FC = (props: CreateBadgeFormProps) => { + + function generateRequest(inputs: ReadonlyArray): CreateBadgeRequest { + const badgeName: string = formGetInputModel(inputs, CreateBadgeFormField.badgeName).value as string + const badgeDesc: string = formGetInputModel(inputs, CreateBadgeFormField.badgeDesc).value as string + const badgeActive: boolean = formGetInputModel(inputs, CreateBadgeFormField.badgeActive).value as boolean + const files: FileList = formGetInputModel(inputs, CreateBadgeFormField.file).value as FileList + + return { + badgeActive, + badgeDesc, + badgeName, + badgeStatus: 'Active', // not used currently thus fixed field + files, + orgID: GamificationConfig.ORG_ID, + } + } + + async function saveAsync(request: CreateBadgeRequest): Promise { + return createBadgeSubmitRequestAsync(request) + .then((createdBadge: GameBadge) => { + props.onSave(createdBadge) + }) + } + + return ( +
+ ) +} + +export default CreateBadgeForm diff --git a/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-form.config.tsx b/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-form.config.tsx new file mode 100644 index 000000000..6621857d8 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-form.config.tsx @@ -0,0 +1,89 @@ +import { FormDefinition, IconOutline, validatorRequired } from '../../../../../lib' +import { GamificationConfig } from '../../../game-config' + +export enum CreateBadgeFormField { + badgeActive = 'badgeActive', + badgeName = 'badgeName', + badgeDesc = 'badgeDesc', + file = 'file', +} + +export const createBadgeFormDef: FormDefinition = { + buttons: { + primaryGroup: [ + { + buttonStyle: 'primary', + isSubmit: true, + label: 'Save Badge', + onClick: (e) => { }, + size: 'lg', + type: 'submit', + }, + ], + secondaryGroup: [ + { + buttonStyle: 'icon-bordered', + icon: IconOutline.ChevronLeftIcon, + route: '/gamification-admin', + size: 'lg', + }, + ], + }, + groups: [ + { + inputs: [ + { + fileConfig: { + acceptFileType: GamificationConfig.ACCEPTED_BADGE_MIME_TYPES, + maxFileSize: GamificationConfig.MAX_BADGE_IMAGE_FILE_SIZE, + }, + name: CreateBadgeFormField.file, + type: 'image-picker', + validators: [ + { + validator: validatorRequired, + }, + ], + }, + ], + }, + { + inputs: [ + { + label: 'Badge Name', + name: CreateBadgeFormField.badgeName, + placeholder: 'Enter badge name', + type: 'text', + validators: [ + { + validator: validatorRequired, + }, + ], + }, + { + label: 'Badge Description', + name: CreateBadgeFormField.badgeDesc, + placeholder: 'Enter badge description, details, how to get awarded info', + type: 'textarea', + validators: [ + { + validator: validatorRequired, + }, + ], + }, + { + checked: true, + label: 'Activate Badge', + name: CreateBadgeFormField.badgeActive, + type: 'checkbox', + }, + ], + }, + ], + groupsOptions: { + groupWrapStyles: { + gridTemplateColumns: '160px 1fr', + }, + renderGroupDividers: false, + }, +} diff --git a/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-functions/create-badge-store/create-badge-request.model.ts b/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-functions/create-badge-store/create-badge-request.model.ts new file mode 100644 index 000000000..a0ed3c677 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-functions/create-badge-store/create-badge-request.model.ts @@ -0,0 +1,8 @@ +export interface CreateBadgeRequest { + badgeActive: boolean + badgeDesc: string + badgeName: string + badgeStatus: string + files: FileList + orgID: string +} diff --git a/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-functions/create-badge-store/create-badge.store.ts b/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-functions/create-badge-store/create-badge.store.ts new file mode 100644 index 000000000..9a2f83fba --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-functions/create-badge-store/create-badge.store.ts @@ -0,0 +1,21 @@ +import { EnvironmentConfig } from '../../../../../../../config' +import { xhrPostAsync } from '../../../../../../../lib' +import { GameBadge } from '../../../../../game-lib' + +import { CreateBadgeRequest } from './create-badge-request.model' + +export async function submitRequestAsync(request: CreateBadgeRequest): Promise { + const url: string = `${EnvironmentConfig.API.V5}/gamification/badges` + + const form: any = new FormData() + + // fill the form + form.append('file', request.files[0]) + form.append('organization_id', request.orgID) + form.append('badge_status', request.badgeStatus) + form.append('badge_name', request.badgeName) + form.append('badge_description', request.badgeDesc) + form.append('active', request.badgeActive ? 'true' : 'false') + + return xhrPostAsync(url, form) +} diff --git a/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-functions/create-badge-store/index.ts b/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-functions/create-badge-store/index.ts new file mode 100644 index 000000000..5f0511634 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-functions/create-badge-store/index.ts @@ -0,0 +1,2 @@ +export * from './create-badge-request.model' +export { submitRequestAsync as createBadgeSubmitRequestAsync } from './create-badge.store' diff --git a/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-functions/create-badge.functions.ts b/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-functions/create-badge.functions.ts new file mode 100644 index 000000000..ddce9a486 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-functions/create-badge.functions.ts @@ -0,0 +1,5 @@ +import { CreateBadgeRequest, createBadgeSubmitRequestAsync } from './create-badge-store' + +export async function submitRequestAsync(request: CreateBadgeRequest): Promise { + return createBadgeSubmitRequestAsync(request) +} diff --git a/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-functions/index.ts b/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-functions/index.ts new file mode 100644 index 000000000..82368d47a --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/create-badge-functions/index.ts @@ -0,0 +1,2 @@ +export { type CreateBadgeRequest } from './create-badge-store' +export { submitRequestAsync as contactSupportSubmitRequestAsync } from './create-badge.functions' diff --git a/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/index.ts b/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/index.ts new file mode 100644 index 000000000..a323b7903 --- /dev/null +++ b/src-ts/tools/gamification-admin/pages/create-badge/create-badge-form/index.ts @@ -0,0 +1,2 @@ +export { default as CreateBadgeForm } from './CreateBadgeForm' +export { createBadgeFormDef } from './create-badge-form.config' diff --git a/src-ts/tools/learn/README.md b/src-ts/tools/learn/README.md index c29536baf..421e50f1f 100644 --- a/src-ts/tools/learn/README.md +++ b/src-ts/tools/learn/README.md @@ -6,7 +6,7 @@ The Learn tool has its own configuration defined in the [/src-ts/tools/learn/lea The default configuration expects both the FCC Client and API to be running locally. In most cases, developers probably won't want to run both locally. ->**See** [/src-ts/tools/learn/learn-config/learn.bsouza.config.ts](/src-ts/tools/learn/learn-config/learn.bsouza.config.ts) for an example of how to override the FCC source URLs to use the dev env config. +>**See** [/src-ts/tools/learn/learn-config/learn.brooke.config.ts](/src-ts/tools/learn/learn-config/learn.brooke.config.ts) for an example of how to override the FCC source URLs to use the dev env config. >**See** the [main app README](/README.md#personal-config) for instructions for creating a personal config. diff --git a/src-ts/tools/learn/course-certificate/certificate-view/CertificateView.tsx b/src-ts/tools/learn/course-certificate/certificate-view/CertificateView.tsx index ead6c3bab..448e75d07 100644 --- a/src-ts/tools/learn/course-certificate/certificate-view/CertificateView.tsx +++ b/src-ts/tools/learn/course-certificate/certificate-view/CertificateView.tsx @@ -19,7 +19,7 @@ import { UserCompletedCertificationsProviderData, useUserCompletedCertifications, } from '../../learn-lib' -import { absoluteRootRoute, getCoursePath } from '../../learn.routes' +import { getCoursePath, getUserCertificateSsr } from '../../learn.routes' import { ActionButton } from './action-button' import { Certificate } from './certificate' @@ -35,6 +35,7 @@ interface CertificateViewProps { } const CertificateView: FC = (props: CertificateViewProps) => { + const navigate: NavigateFunction = useNavigate() const { onCertificationNotCompleted }: CertificateViewProps = props const coursePath: string = getCoursePath(props.provider, props.certification) @@ -51,7 +52,18 @@ const CertificateView: FC = (props: CertificateViewProps) ready: courseReady, }: CoursesProviderData = useCourses(props.provider, props.certification) - const certificationTitle: string = `${userName || props.profile.handle} - ${course?.title} Certification` + function getCertTitle(user: string): string { + return `${user} - ${course?.title} Certification` + } + + const certUrl: string = getUserCertificateSsr( + props.provider, + props.certification, + props.profile.handle, + getCertTitle(props.profile.handle), + ) + + const certificationTitle: string = getCertTitle(userName || props.profile.handle) const { certifications: [completedCertificate], @@ -69,7 +81,7 @@ const CertificateView: FC = (props: CertificateViewProps) }: AllCertificationsProviderData = useAllCertifications( props.provider, course?.certificationId, - {enabled: !!course?.certificationId} + { enabled: !!course?.certificationId } ) const ready: boolean = useMemo(() => ( @@ -180,15 +192,15 @@ const CertificateView: FC = (props: CertificateViewProps) /> )} diff --git a/src-ts/tools/learn/course-certificate/certificate-view/certificate/Certificate.tsx b/src-ts/tools/learn/course-certificate/certificate-view/certificate/Certificate.tsx index af4d8ca5b..be076dec3 100644 --- a/src-ts/tools/learn/course-certificate/certificate-view/certificate/Certificate.tsx +++ b/src-ts/tools/learn/course-certificate/certificate-view/certificate/Certificate.tsx @@ -1,6 +1,7 @@ import classNames from 'classnames' import { FC, MutableRefObject } from 'react' +import { LearnConfig } from '../../../learn-config' import { LearnCertificateTrackType } from '../../../learn-lib' import { CertificateBgPattern } from './certificate-bg-pattern' @@ -13,7 +14,7 @@ import { ReactComponent as FccLogoSvg } from './vendor-fcc-logo.svg' interface CertificateProps { completedDate?: string course?: string - elRef?: MutableRefObject + elRef?: MutableRefObject provider?: string tcHandle?: string type?: LearnCertificateTrackType @@ -21,10 +22,19 @@ interface CertificateProps { } const Certificate: FC = (props: CertificateProps) => { + const certificateType: LearnCertificateTrackType = props.type ?? 'DEV' + const elementSelector: { [attr: string]: string } = { + [LearnConfig.CERT_ELEMENT_SELECTOR.attribute]: LearnConfig.CERT_ELEMENT_SELECTOR.value, + } + return ( -
+

Topcoder Academy

Certificate of Course Completion

diff --git a/src-ts/tools/learn/course-certificate/certificate-view/certificate/certificate-bg-pattern/CertificateBgPattern.module.scss b/src-ts/tools/learn/course-certificate/certificate-view/certificate/certificate-bg-pattern/CertificateBgPattern.module.scss index 2075827eb..02c4c5bec 100644 --- a/src-ts/tools/learn/course-certificate/certificate-view/certificate/certificate-bg-pattern/CertificateBgPattern.module.scss +++ b/src-ts/tools/learn/course-certificate/certificate-view/certificate/certificate-bg-pattern/CertificateBgPattern.module.scss @@ -24,14 +24,23 @@ &:global(.theme-qa) { background-image: $tc-qa-grad; } + &:global(.theme-datascience) { background-image: $tc-datascience-grad; } + &:global(.theme-interview) { + background-image: $tc-interview-grad; + } + + &:global(.theme-security) { + background-image: $tc-security-grad; + } + > div { position: absolute; top: 0; - left: 0; + left: -1px; width: 100%; height: 100%; z-index: 1; @@ -42,7 +51,7 @@ } &:global(.wave-bg) { - background: url('./wave-bg.png') -1px 0 repeat-y; + background: url('./wave-bg.png') 0 0 repeat-y; background-size: 400px 116px; } } diff --git a/src-ts/tools/learn/course-certificate/certificate-view/certificate/includes.scss b/src-ts/tools/learn/course-certificate/certificate-view/certificate/includes.scss index 6ad95c3af..6db0d3dd5 100644 --- a/src-ts/tools/learn/course-certificate/certificate-view/certificate/includes.scss +++ b/src-ts/tools/learn/course-certificate/certificate-view/certificate/includes.scss @@ -6,10 +6,12 @@ $tc-dev-grad: linear-gradient(84.92deg, #048467 2.08%, #064871 97.43%); $tc-design-grad: linear-gradient(84.92deg, #065D6E 2.08%, #06596E 2.09%, #3E3B91 97.43%); $tc-qa-grad: linear-gradient(84.92deg, #363D8C 2.08%, #723390 97.43%); $tc-datascience-grad: linear-gradient(84.92deg, #723390 2.08%, #8C384F 97.43%); +$tc-interview-grad: linear-gradient(84.92deg, #048467 2.08%, #064871 33.85%, #6831A8 66.15%, #8C384D 97.43%); +$tc-security-grad: linear-gradient(84.92deg, #048467 2.08%, #064871 97.43%); @mixin grad-text-color($grad) { background: $grad; -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; -} \ No newline at end of file +} diff --git a/src-ts/tools/learn/course-details/CourseDetailsPage.module.scss b/src-ts/tools/learn/course-details/CourseDetailsPage.module.scss index 96aa7ad71..605a7afe9 100644 --- a/src-ts/tools/learn/course-details/CourseDetailsPage.module.scss +++ b/src-ts/tools/learn/course-details/CourseDetailsPage.module.scss @@ -47,10 +47,14 @@ @extend .body-main; margin-top: $space-xxl; } - + p { margin: $space-sm 0; } + + :global(.details.mtop) { + margin-top: $space-xxl; + } } .coming-soon { diff --git a/src-ts/tools/learn/course-details/CourseDetailsPage.tsx b/src-ts/tools/learn/course-details/CourseDetailsPage.tsx index 756bbdfb1..1795fecfd 100644 --- a/src-ts/tools/learn/course-details/CourseDetailsPage.tsx +++ b/src-ts/tools/learn/course-details/CourseDetailsPage.tsx @@ -55,7 +55,9 @@ const CourseDetailsPage: FC<{}> = () => { const { certification: certificate, ready: certificateReady, - }: AllCertificationsProviderData = useAllCertifications(routeParams.provider, course?.certificationId) + }: AllCertificationsProviderData = useAllCertifications(routeParams.provider, course?.certificationId, { + enabled: courseReady, + }) // this looks better than finding workarounds for cyclomatic-complexity /* tslint:disable:cyclomatic-complexity */ @@ -99,6 +101,46 @@ const CourseDetailsPage: FC<{}> = () => { ) } + function getPrerequisites(): ReactNode { + if (!course) { + return + } + + return progress?.status === UserCertificationProgressStatus.completed ? ( + <> + ) : ( + <> +

Prerequisites

+ +
+ There are no prerequisites for this course. + The course content is appropriate for new learners with no previous experience in this topic. +
+ + ) + } + + function getCompletionSuggestion(): ReactNode { + if (!course) { + return + } + + return progress?.status === UserCertificationProgressStatus.completed ? ( + <> + ) : ( + !!course.completionSuggestions?.length && ( + <> +

Suggestions for completing this course

+ +

') }} + >
+ + ) + ) + } + function getFooter(): ReactNode { if (!resourceProvider) { return @@ -141,6 +183,8 @@ const CourseDetailsPage: FC<{}> = () => {
{getDescription()} + {getPrerequisites()} + {getCompletionSuggestion()}
diff --git a/src-ts/tools/learn/free-code-camp/FreeCodeCamp.module.scss b/src-ts/tools/learn/free-code-camp/FreeCodeCamp.module.scss index abf59cf76..4be0344b3 100644 --- a/src-ts/tools/learn/free-code-camp/FreeCodeCamp.module.scss +++ b/src-ts/tools/learn/free-code-camp/FreeCodeCamp.module.scss @@ -48,32 +48,3 @@ } } } - -.course-outline-pane { - position: absolute; - left: 0; - top: 0; - bottom: 0; - - @include ltemd { - position: relative; - top: auto; - left: auto; - bottom: auto; - flex: 0 0 auto; - } -} - -.course-outline-wrap { - width: 406px; - - @include ltemd { - width: 100%; - } -} - -.course-outline-title { - @extend .body-main-bold; - flex: 0 0 auto; - margin-bottom: $space-xl; -} \ No newline at end of file diff --git a/src-ts/tools/learn/free-code-camp/FreeCodeCamp.tsx b/src-ts/tools/learn/free-code-camp/FreeCodeCamp.tsx index 7552abd1f..83af1a695 100644 --- a/src-ts/tools/learn/free-code-camp/FreeCodeCamp.tsx +++ b/src-ts/tools/learn/free-code-camp/FreeCodeCamp.tsx @@ -19,8 +19,6 @@ import { ProfileContextData, } from '../../../lib' import { - CollapsiblePane, - CourseOutline, CoursesProviderData, LearnLesson, LearnModule, @@ -29,6 +27,7 @@ import { useCourses, useLearnBreadcrumb, useLessonProvider, + userCertificationProgressCompleteCourseAsync, UserCertificationProgressProviderData, userCertificationProgressStartAsync, UserCertificationProgressStatus, @@ -39,6 +38,7 @@ import { import { getCertificationCompletedPath, getCoursePath, getLessonPathFromModule } from '../learn.routes' import { FccFrame } from './fcc-frame' +import { FccSidebar } from './fcc-sidebar' import styles from './FreeCodeCamp.module.scss' import { TitleNav } from './title-nav' @@ -258,31 +258,40 @@ const FreeCodeCamp: FC<{}> = () => { } useEffect(() => { + + // if we don't yet have the user's handle, + // or if the cert isn't complete, + // or the cert isn't in progress, + // there's nothing to do if ( - certificateProgress && - certificateProgress.courseProgressPercentage === 100 && - certificateProgress.status === UserCertificationProgressStatus.inProgress + !profile?.handle + || certificateProgress?.certificationProgressPercentage !== 100 + || certificateProgress?.status !== UserCertificationProgressStatus.inProgress ) { - userCertificationProgressUpdateAsync( - certificateProgress.id, - UserCertificationUpdateProgressActions.completeCertificate, - {} - ) - .then(setCertificateProgress) - .then(() => { - const completedPath: string = getCertificationCompletedPath( - providerParam, - certificationParam - ) - - navigate(completedPath) - }) + return } + + // it's safe to complete the course + userCertificationProgressCompleteCourseAsync( + certificateProgress.id, + certificationParam, + profile.handle, + providerParam, + ) + .then(setCertificateProgress) + .then(() => { + const completedPath: string = getCertificationCompletedPath( + providerParam, + certificationParam + ) + navigate(completedPath) + }) }, [ certificateProgress, certificationParam, navigate, providerParam, + profile?.handle, setCertificateProgress, ]) @@ -362,24 +371,13 @@ const FreeCodeCamp: FC<{}> = () => { {lesson && (
-
- isOpen && refetchProgress()} - > -
-
- {courseData?.title} -
- -
-
-
+
void +} + +const FccSidebar: FC = (props: FccSidebarProps) => { + const [isOpen, setIsOpen]: [boolean, Dispatch>] = useState(false) + + const handleToggle: (isOutlineOpen: boolean) => void = (isOutlineOpen: boolean) => { + setIsOpen(isOutlineOpen) + if (isOutlineOpen) { + props.refetchProgress() + } + } + + return ( +
+ +
+
+ {props.courseData?.title} +
+ setIsOpen(false)} + /> +
+
+
+ ) +} + +export default FccSidebar diff --git a/src-ts/tools/learn/free-code-camp/fcc-sidebar/index.ts b/src-ts/tools/learn/free-code-camp/fcc-sidebar/index.ts new file mode 100644 index 000000000..5f95796fc --- /dev/null +++ b/src-ts/tools/learn/free-code-camp/fcc-sidebar/index.ts @@ -0,0 +1 @@ +export { default as FccSidebar } from './FccSidebar' diff --git a/src-ts/tools/learn/learn-config/learn-config.model.ts b/src-ts/tools/learn/learn-config/learn-config.model.ts index 783fe51ff..057d953ec 100644 --- a/src-ts/tools/learn/learn-config/learn-config.model.ts +++ b/src-ts/tools/learn/learn-config/learn-config.model.ts @@ -1,4 +1,9 @@ export interface LearnConfigModel { API: string + CERT_DOMAIN: string + CERT_ELEMENT_SELECTOR: { + attribute: string, + value: string, + } CLIENT: string } diff --git a/src-ts/tools/learn/learn-config/learn.bsouza.config.ts b/src-ts/tools/learn/learn-config/learn.brooke.config.ts similarity index 83% rename from src-ts/tools/learn/learn-config/learn.bsouza.config.ts rename to src-ts/tools/learn/learn-config/learn.brooke.config.ts index ab591ccaa..01676b2af 100644 --- a/src-ts/tools/learn/learn-config/learn.bsouza.config.ts +++ b/src-ts/tools/learn/learn-config/learn.brooke.config.ts @@ -2,7 +2,7 @@ import { LearnConfigModel } from './learn-config.model' import { LearnConfigDefault } from './learn.default.config' import { LearnConfigDev } from './learn.dev.config' -export const LearnConfigBsouza: LearnConfigModel = { +export const LearnConfigBrooke: LearnConfigModel = { ...LearnConfigDev, // API: LearnConfigDefault.API, CLIENT: LearnConfigDefault.CLIENT, diff --git a/src-ts/tools/learn/learn-config/learn.config.ts b/src-ts/tools/learn/learn-config/learn.config.ts index 64706fc50..24cf6ece7 100644 --- a/src-ts/tools/learn/learn-config/learn.config.ts +++ b/src-ts/tools/learn/learn-config/learn.config.ts @@ -1,7 +1,7 @@ import { EnvironmentConfig } from '../../../config' import { LearnConfigModel } from './learn-config.model' -import { LearnConfigBsouza } from './learn.bsouza.config' +import { LearnConfigBrooke } from './learn.brooke.config' import { LearnConfigDefault } from './learn.default.config' import { LearnConfigDev } from './learn.dev.config' import { LearnConfigProd } from './learn.prod.config' @@ -10,8 +10,8 @@ function getConfig(): LearnConfigModel { switch (EnvironmentConfig.ENV) { - case 'bsouza': - return LearnConfigBsouza + case 'brooke': + return LearnConfigBrooke case 'dev': return LearnConfigDev diff --git a/src-ts/tools/learn/learn-config/learn.default.config.ts b/src-ts/tools/learn/learn-config/learn.default.config.ts index 36ca36697..7c6ead230 100644 --- a/src-ts/tools/learn/learn-config/learn.default.config.ts +++ b/src-ts/tools/learn/learn-config/learn.default.config.ts @@ -2,5 +2,10 @@ import { LearnConfigModel } from './learn-config.model' export const LearnConfigDefault: LearnConfigModel = { API: 'http://localhost:3001/v5/learning-paths', + CERT_DOMAIN: 'https://certificate.topcoder-dev.com', + CERT_ELEMENT_SELECTOR: { + attribute: 'data-id', + value: 'certificate-container', + }, CLIENT: 'https://fcc.topcoder-dev.com:4431', } diff --git a/src-ts/tools/learn/learn-config/learn.dev.config.ts b/src-ts/tools/learn/learn-config/learn.dev.config.ts index 8ee03c5fe..60c00ef28 100644 --- a/src-ts/tools/learn/learn-config/learn.dev.config.ts +++ b/src-ts/tools/learn/learn-config/learn.dev.config.ts @@ -1,6 +1,8 @@ import { LearnConfigModel } from './learn-config.model' +import { LearnConfigDefault } from './learn.default.config' export const LearnConfigDev: LearnConfigModel = { + ...LearnConfigDefault, API: 'https://api.topcoder-dev.com/v5/learning-paths', CLIENT: 'https://freecodecamp.topcoder-dev.com', } diff --git a/src-ts/tools/learn/learn-config/learn.prod.config.ts b/src-ts/tools/learn/learn-config/learn.prod.config.ts index ab162a8f8..d7aeb4cba 100644 --- a/src-ts/tools/learn/learn-config/learn.prod.config.ts +++ b/src-ts/tools/learn/learn-config/learn.prod.config.ts @@ -1,6 +1,9 @@ import { LearnConfigModel } from './learn-config.model' +import { LearnConfigDefault } from './learn.default.config' export const LearnConfigProd: LearnConfigModel = { + ...LearnConfigDefault, API: 'https://api.topcoder.com/v5/learning-paths', + CERT_DOMAIN: 'https://certificate.topcoder.com', CLIENT: 'https://freecodecamp.topcoder.com', } diff --git a/src-ts/tools/learn/learn-lib/all-certifications-provider/all-certifications-functions/learn-certificate-track-type.ts b/src-ts/tools/learn/learn-lib/all-certifications-provider/all-certifications-functions/learn-certificate-track-type.ts index 89e1bc2d9..89fe2b1fa 100644 --- a/src-ts/tools/learn/learn-lib/all-certifications-provider/all-certifications-functions/learn-certificate-track-type.ts +++ b/src-ts/tools/learn/learn-lib/all-certifications-provider/all-certifications-functions/learn-certificate-track-type.ts @@ -1 +1 @@ -export type LearnCertificateTrackType = 'QA'|'DEV'|'DATASCIENCE'|'DESIGN' +export type LearnCertificateTrackType = 'QA'|'DEV'|'DATASCIENCE'|'DESIGN'|'INTERVIEW'|'SECURITY' diff --git a/src-ts/tools/learn/learn-lib/all-certifications-provider/all-certifications-functions/learn-certification.model.ts b/src-ts/tools/learn/learn-lib/all-certifications-provider/all-certifications-functions/learn-certification.model.ts index 9fecdd7dd..f592b7c85 100644 --- a/src-ts/tools/learn/learn-lib/all-certifications-provider/all-certifications-functions/learn-certification.model.ts +++ b/src-ts/tools/learn/learn-lib/all-certifications-provider/all-certifications-functions/learn-certification.model.ts @@ -11,6 +11,7 @@ export interface LearnCertification extends LearnModelBase { key: string providerCrertificationId: string providerName: string + publishedAt?: Date state: 'active' | 'coming-soon' title: string trackType: LearnCertificateTrackType diff --git a/src-ts/tools/learn/learn-lib/all-certifications-provider/all-certifications-provider-data.model.ts b/src-ts/tools/learn/learn-lib/all-certifications-provider/all-certifications-provider-data.model.ts index cc5d28762..6c5dcb1b1 100755 --- a/src-ts/tools/learn/learn-lib/all-certifications-provider/all-certifications-provider-data.model.ts +++ b/src-ts/tools/learn/learn-lib/all-certifications-provider/all-certifications-provider-data.model.ts @@ -3,7 +3,6 @@ import { LearnCertification } from './all-certifications-functions' export interface AllCertificationsProviderData { certification?: LearnCertification certifications: Array - certificationsCount: number loading: boolean ready: boolean } diff --git a/src-ts/tools/learn/learn-lib/all-certifications-provider/all-certifications.provider.tsx b/src-ts/tools/learn/learn-lib/all-certifications-provider/all-certifications.provider.tsx index 5bbb14210..ed27651e5 100644 --- a/src-ts/tools/learn/learn-lib/all-certifications-provider/all-certifications.provider.tsx +++ b/src-ts/tools/learn/learn-lib/all-certifications-provider/all-certifications.provider.tsx @@ -12,12 +12,10 @@ export function useAllCertifications( certificationId?: string, options?: CertificationsAllProviderOptions ): AllCertificationsProviderData { - const [state, setState]: [AllCertificationsProviderData, Dispatch>] = useState({ certifications: [], - certificationsCount: 0, loading: false, ready: false, }) @@ -34,23 +32,10 @@ export function useAllCertifications( allCertificationsGetAsync(provider, certificationId) .then((certifications) => { - const certs: { - certification: LearnCertification; - certifications?: undefined; - } | { - certification?: undefined; - certifications: Array; - } = certificationId - ? { - certification: certifications as unknown as LearnCertification, - } - : { - certifications: [...certifications], - } setState((prevState) => ({ ...prevState, - ...certs, - certificationsCount: certifications.length, + certification: !certificationId ? undefined : certifications as unknown as LearnCertification, + certifications: certificationId ? [] : [...certifications], loading: false, ready: true, })) diff --git a/src-ts/tools/learn/learn-lib/collapsible-pane/CollapsiblePane.tsx b/src-ts/tools/learn/learn-lib/collapsible-pane/CollapsiblePane.tsx index e73399a51..dadf18d74 100644 --- a/src-ts/tools/learn/learn-lib/collapsible-pane/CollapsiblePane.tsx +++ b/src-ts/tools/learn/learn-lib/collapsible-pane/CollapsiblePane.tsx @@ -1,13 +1,24 @@ import classNames from 'classnames' import { noop } from 'lodash' -import { Dispatch, FC, ReactNode, SetStateAction, useCallback, useState } from 'react' +import { + Dispatch, + FC, + MutableRefObject, + ReactNode, + SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from 'react' -import { IconSolid } from '../../../../lib' +import { IconSolid, useClickOutside } from '../../../../lib' import styles from './CollapsiblePane.module.scss' interface CollapsiblePaneProps { children: ReactNode + isOpen?: boolean onToggle?: (isOpen: boolean) => void position?: 'to-left'|'to-right' title: string @@ -17,13 +28,26 @@ const CollapsiblePane: FC = (props: CollapsiblePaneProps) const {onToggle = noop}: CollapsiblePaneProps = props const [isOpen, setIsOpen]: [boolean, Dispatch>] = useState(false) + const elRef: MutableRefObject = useRef() + const toggle: () => void = useCallback(() => { setIsOpen(!isOpen) onToggle(!isOpen) }, [isOpen, onToggle]) + const close: () => void = useCallback(() => { + setIsOpen(false) + onToggle(false) + }, [onToggle]) + + useEffect(() => { + setIsOpen(!!props.isOpen) + }, [props.isOpen]) + + useClickOutside(elRef.current, close, isOpen) + return ( -
>} = { DATASCIENCE: DataScienceBadge, DESIGN: DesignBadge, DEV: DevelopBadge, + INTERVIEW: InterviewBadge, QA: QABadge, + SECURITY: SecurityBadge, } const badgesImgMap: {[key: string]: string} = { DATASCIENCE: DataScienceBadgeImg, DESIGN: DesignBadgeImg, DEV: DevelopBadgeImg, + INTERVIEW: InterviewBadgeImg, QA: QABadgeImg, + SECURITY: SecurityBadgeImg, } export function getBadge(badgeType: keyof typeof badgesMap): FC> { diff --git a/src-ts/tools/learn/learn-lib/course-badge/badges/datascience-badge.svg b/src-ts/tools/learn/learn-lib/course-badge/badges/datascience-badge.svg index 3156d5bd6..876a6f787 100644 --- a/src-ts/tools/learn/learn-lib/course-badge/badges/datascience-badge.svg +++ b/src-ts/tools/learn/learn-lib/course-badge/badges/datascience-badge.svg @@ -1,11 +1,11 @@ - - - + + + diff --git a/src-ts/tools/learn/learn-lib/course-badge/badges/design-badge.svg b/src-ts/tools/learn/learn-lib/course-badge/badges/design-badge.svg index e00db538b..170c58fc2 100644 --- a/src-ts/tools/learn/learn-lib/course-badge/badges/design-badge.svg +++ b/src-ts/tools/learn/learn-lib/course-badge/badges/design-badge.svg @@ -1,9 +1,4 @@ - - - - - @@ -11,4 +6,9 @@ + + + + + diff --git a/src-ts/tools/learn/learn-lib/course-badge/badges/develop-badge.svg b/src-ts/tools/learn/learn-lib/course-badge/badges/develop-badge.svg index 34291a29e..1d50e7501 100644 --- a/src-ts/tools/learn/learn-lib/course-badge/badges/develop-badge.svg +++ b/src-ts/tools/learn/learn-lib/course-badge/badges/develop-badge.svg @@ -1,11 +1,11 @@ - - - + + + diff --git a/src-ts/tools/learn/learn-lib/course-badge/badges/index.ts b/src-ts/tools/learn/learn-lib/course-badge/badges/index.ts index e604adec2..d2094a3ed 100644 --- a/src-ts/tools/learn/learn-lib/course-badge/badges/index.ts +++ b/src-ts/tools/learn/learn-lib/course-badge/badges/index.ts @@ -4,16 +4,24 @@ import DesignBadgeImg from './design-badge.png' import { ReactComponent as DesignBadge } from './design-badge.svg' import DevelopBadgeImg from './develop-badge.png' import { ReactComponent as DevelopBadge } from './develop-badge.svg' +import InterviewBadgeImg from './interview-badge.png' +import { ReactComponent as InterviewBadge } from './interview-badge.svg' import QABadgeImg from './qa-badge.png' import { ReactComponent as QABadge } from './qa-badge.svg' +import SecurityBadgeImg from './security-badge.png' +import { ReactComponent as SecurityBadge } from './security-badge.svg' export { DataScienceBadge, - DesignBadge, - DevelopBadge, - QABadge, DataScienceBadgeImg, + DesignBadge, DesignBadgeImg, + DevelopBadge, DevelopBadgeImg, + InterviewBadge, + InterviewBadgeImg, + QABadge, QABadgeImg, + SecurityBadge, + SecurityBadgeImg, } diff --git a/src-ts/tools/learn/learn-lib/course-badge/badges/interview-badge.png b/src-ts/tools/learn/learn-lib/course-badge/badges/interview-badge.png new file mode 100644 index 000000000..e8786d8ef Binary files /dev/null and b/src-ts/tools/learn/learn-lib/course-badge/badges/interview-badge.png differ diff --git a/src-ts/tools/learn/learn-lib/course-badge/badges/interview-badge.svg b/src-ts/tools/learn/learn-lib/course-badge/badges/interview-badge.svg new file mode 100644 index 000000000..6381ed854 --- /dev/null +++ b/src-ts/tools/learn/learn-lib/course-badge/badges/interview-badge.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src-ts/tools/learn/learn-lib/course-badge/badges/qa-badge.svg b/src-ts/tools/learn/learn-lib/course-badge/badges/qa-badge.svg index 636274783..52bed5452 100644 --- a/src-ts/tools/learn/learn-lib/course-badge/badges/qa-badge.svg +++ b/src-ts/tools/learn/learn-lib/course-badge/badges/qa-badge.svg @@ -1,11 +1,11 @@ - - - + + + diff --git a/src-ts/tools/learn/learn-lib/course-badge/badges/security-badge.png b/src-ts/tools/learn/learn-lib/course-badge/badges/security-badge.png new file mode 100644 index 000000000..8f9526b57 Binary files /dev/null and b/src-ts/tools/learn/learn-lib/course-badge/badges/security-badge.png differ diff --git a/src-ts/tools/learn/learn-lib/course-badge/badges/security-badge.svg b/src-ts/tools/learn/learn-lib/course-badge/badges/security-badge.svg new file mode 100644 index 000000000..0bcc7b7b9 --- /dev/null +++ b/src-ts/tools/learn/learn-lib/course-badge/badges/security-badge.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src-ts/tools/learn/learn-lib/course-outline/CourseOutline.tsx b/src-ts/tools/learn/learn-lib/course-outline/CourseOutline.tsx index 4a1df0e45..65dd085cc 100644 --- a/src-ts/tools/learn/learn-lib/course-outline/CourseOutline.tsx +++ b/src-ts/tools/learn/learn-lib/course-outline/CourseOutline.tsx @@ -16,6 +16,7 @@ import styles from './CourseOutline.module.scss' interface CourseOutlineProps { course?: LearnCourse currentStep?: string + onItemNavigate: (item: LearnLesson) => void progress?: LearnUserCertificationProgress ready?: boolean } @@ -52,6 +53,8 @@ const CourseOutline: FC = (props: CourseOutlineProps) => { progress={props.progress?.modules} shortDescription={module.meta.introCopy} title={module.meta.name} + onItemClick={props.onItemNavigate} + isAssessment={module.meta.isAssessment} /> ))}
diff --git a/src-ts/tools/learn/learn-lib/course-outline/collapsible-item/CollapsibleItem.module.scss b/src-ts/tools/learn/learn-lib/course-outline/collapsible-item/CollapsibleItem.module.scss index a5ed5f680..04597ce09 100644 --- a/src-ts/tools/learn/learn-lib/course-outline/collapsible-item/CollapsibleItem.module.scss +++ b/src-ts/tools/learn/learn-lib/course-outline/collapsible-item/CollapsibleItem.module.scss @@ -57,7 +57,7 @@ @include icon-xxl; } - + .wrap:global(.collapsed) & svg { transform: rotate(180deg); } @@ -75,13 +75,17 @@ .short-desc { margin-top: $space-sm; + + pre { + display: inline; + } } .summary { display: flex; color: $tc-white; margin-top: $border-xs; - + > * + * { &:before { content: ""; diff --git a/src-ts/tools/learn/learn-lib/course-outline/collapsible-item/CollapsibleItem.tsx b/src-ts/tools/learn/learn-lib/course-outline/collapsible-item/CollapsibleItem.tsx index 2d91608c2..1af065230 100644 --- a/src-ts/tools/learn/learn-lib/course-outline/collapsible-item/CollapsibleItem.tsx +++ b/src-ts/tools/learn/learn-lib/course-outline/collapsible-item/CollapsibleItem.tsx @@ -17,10 +17,12 @@ interface CollapsibleListItem { interface CollapsibleItemProps { active?: string duration: LearnModule['meta']['estimatedCompletionTime'] + isAssessment: boolean itemId?: (item: any) => string items: Array lessonsCount: number moduleKey: string + onItemClick: (item: any) => void path?: (item: any) => string progress?: LearnUserCertificationProgress['modules'] shortDescription: Array @@ -30,8 +32,6 @@ interface CollapsibleItemProps { const CollapsibleItem: FC = (props: CollapsibleItemProps) => { const [isOpen, setIsOpen]: [boolean, Dispatch>] = useState(false) - const isAssessment: boolean = props.lessonsCount === 1 - const toggle: () => void = useCallback(() => { setIsOpen(open => !open) }, []) @@ -72,6 +72,7 @@ const CollapsibleItem: FC = (props: CollapsibleItemProps)
  • props.onItemClick(item)} > {props.path ? ( @@ -87,7 +88,7 @@ const CollapsibleItem: FC = (props: CollapsibleItemProps)
    - {isAssessment && ( + {props.isAssessment && (
    assessment
    @@ -105,10 +106,12 @@ const CollapsibleItem: FC = (props: CollapsibleItemProps) {props.lessonsCount} Lessons
    - - - {props.duration.value} {props.duration.units} - + {props.duration.value !== 0 && ( + + + {props.duration.value} {props.duration.units} + + )}
    ') }}> diff --git a/src-ts/tools/learn/learn-lib/course-outline/status-icon/StatusIcon.module.scss b/src-ts/tools/learn/learn-lib/course-outline/status-icon/StatusIcon.module.scss index e22b9512b..72f6bc564 100755 --- a/src-ts/tools/learn/learn-lib/course-outline/status-icon/StatusIcon.module.scss +++ b/src-ts/tools/learn/learn-lib/course-outline/status-icon/StatusIcon.module.scss @@ -3,4 +3,7 @@ .checkmark { @include icon-size(26); flex: 0 0 auto; -} \ No newline at end of file + svg { + @include icon-full; + } +} diff --git a/src-ts/tools/learn/learn-lib/course-outline/step-icon/StepIcon.module.scss b/src-ts/tools/learn/learn-lib/course-outline/step-icon/StepIcon.module.scss index 1b331837e..98272f548 100755 --- a/src-ts/tools/learn/learn-lib/course-outline/step-icon/StepIcon.module.scss +++ b/src-ts/tools/learn/learn-lib/course-outline/step-icon/StepIcon.module.scss @@ -6,6 +6,7 @@ color: $black-20; .icon { + @include icon-xl; border-radius: 50%; flex: 0 0 auto; @@ -21,6 +22,7 @@ background: $black-40; > svg { + @include icon-full; margin: -1 * $border; } } @@ -43,4 +45,4 @@ background: $tc-white; } } -} \ No newline at end of file +} diff --git a/src-ts/tools/learn/learn-lib/course-outline/step-icon/StepIcon.tsx b/src-ts/tools/learn/learn-lib/course-outline/step-icon/StepIcon.tsx index 78c8ac9b8..b5fd8b2a5 100755 --- a/src-ts/tools/learn/learn-lib/course-outline/step-icon/StepIcon.tsx +++ b/src-ts/tools/learn/learn-lib/course-outline/step-icon/StepIcon.tsx @@ -24,7 +24,7 @@ const StepIcon: FC = (props: StepIconProps) => { return ( <> {props.label && props.completed && ( - + )} {!props.label && ( diff --git a/src-ts/tools/learn/learn-lib/courses-provider/courses-functions/learn-course.model.ts b/src-ts/tools/learn/learn-lib/courses-provider/courses-functions/learn-course.model.ts index 34a4e2442..b9c7010a4 100644 --- a/src-ts/tools/learn/learn-lib/courses-provider/courses-functions/learn-course.model.ts +++ b/src-ts/tools/learn/learn-lib/courses-provider/courses-functions/learn-course.model.ts @@ -4,6 +4,7 @@ import { LearnModule } from '../../lesson-provider' export interface LearnCourse extends LearnModelBase { certification: string certificationId: string + completionSuggestions: Array estimatedCompletionTime: { units: string value: number diff --git a/src-ts/tools/learn/learn-lib/curriculum-summary/CurriculumSummary.tsx b/src-ts/tools/learn/learn-lib/curriculum-summary/CurriculumSummary.tsx index 489a677a8..071ce6e86 100644 --- a/src-ts/tools/learn/learn-lib/curriculum-summary/CurriculumSummary.tsx +++ b/src-ts/tools/learn/learn-lib/curriculum-summary/CurriculumSummary.tsx @@ -11,6 +11,8 @@ interface CurriculumSummaryProps { } const CurriculumSummary: FC = (props: CurriculumSummaryProps) => { + const hasTimeEstimate: boolean = props.completionHours?.value !== 0 + return (
    @@ -32,10 +34,10 @@ const CurriculumSummary: FC = (props: CurriculumSummaryP

    - {props.completionHours?.value ?? 0} + {hasTimeEstimate ? props.completionHours?.value : (<> )}

    - {props.completionHours?.units ?? 'Hours'} + {hasTimeEstimate ? (props.completionHours?.units ?? 'Hours') : 'Times vary'}
    diff --git a/src-ts/tools/learn/learn-lib/functions/learn.factory.ts b/src-ts/tools/learn/learn-lib/functions/learn.factory.ts index 8d9d19830..308d13da2 100644 --- a/src-ts/tools/learn/learn-lib/functions/learn.factory.ts +++ b/src-ts/tools/learn/learn-lib/functions/learn.factory.ts @@ -1,5 +1,6 @@ interface LearnResponseModel { createdAt?: string | Date + publishedAt?: string | Date updatedAt?: string | Date } @@ -11,6 +12,9 @@ export function create(item: T): T { if (typeof item?.updatedAt === 'string') { item.updatedAt = new Date(item.updatedAt) } + if (typeof item?.publishedAt === 'string') { + item.publishedAt = new Date(item.publishedAt) + } return item } diff --git a/src-ts/tools/learn/learn-lib/lesson-provider/learn-module-meta.model.ts b/src-ts/tools/learn/learn-lib/lesson-provider/learn-module-meta.model.ts index 1c8d08b1f..bb2a59385 100644 --- a/src-ts/tools/learn/learn-lib/lesson-provider/learn-module-meta.model.ts +++ b/src-ts/tools/learn/learn-lib/lesson-provider/learn-module-meta.model.ts @@ -5,6 +5,6 @@ export interface LearnModuleMeta { value: number } introCopy: Array - lessonCount: number + isAssessment: boolean name: string } diff --git a/src-ts/tools/learn/learn-lib/my-course-card/in-progress/InProgress.module.scss b/src-ts/tools/learn/learn-lib/my-course-card/in-progress/InProgress.module.scss index 1b525b6fd..152d7f6fb 100644 --- a/src-ts/tools/learn/learn-lib/my-course-card/in-progress/InProgress.module.scss +++ b/src-ts/tools/learn/learn-lib/my-course-card/in-progress/InProgress.module.scss @@ -8,10 +8,6 @@ display: flex; - &.large { - padding: $space-xxxxl; - } - @include ltelg { &, &.large { padding: $space-lg; @@ -119,8 +115,8 @@ .started-date { font-weight: bold; - + @include ltemd { order: -1; } -} \ No newline at end of file +} diff --git a/src-ts/tools/learn/learn-lib/user-certifications-provider/user-certifications-functions/index.ts b/src-ts/tools/learn/learn-lib/user-certifications-provider/user-certifications-functions/index.ts index 507435d3c..9b8dd3889 100755 --- a/src-ts/tools/learn/learn-lib/user-certifications-provider/user-certifications-functions/index.ts +++ b/src-ts/tools/learn/learn-lib/user-certifications-provider/user-certifications-functions/index.ts @@ -3,6 +3,7 @@ export * from './learn-module-progress.model' export * from './user-certification-progress-status.enum' export * from './user-certification-update-progress-actions.enum' export { + completeCourse as userCertificationProgressCompleteCourseAsync, getAsync as userCertificationProgressGetAsync, startAsync as userCertificationProgressStartAsync, updateAsync as userCertificationProgressUpdateAsync diff --git a/src-ts/tools/learn/learn-lib/user-certifications-provider/user-certifications-functions/learn-user-certification-progress.model.ts b/src-ts/tools/learn/learn-lib/user-certifications-provider/user-certifications-functions/learn-user-certification-progress.model.ts index 88100541b..2d9dc6f78 100644 --- a/src-ts/tools/learn/learn-lib/user-certifications-provider/user-certifications-functions/learn-user-certification-progress.model.ts +++ b/src-ts/tools/learn/learn-lib/user-certifications-provider/user-certifications-functions/learn-user-certification-progress.model.ts @@ -7,6 +7,8 @@ export interface LearnUserCertificationProgress extends LearnModelBase { academicHonestyPolicyAcceptedAt?: number, certification: string certificationId: string + certificationProgressPercentage: number + certType: 'certification' completedDate?: string courseId: string courseKey: string diff --git a/src-ts/tools/learn/learn-lib/user-certifications-provider/user-certifications-functions/user-certification-progress.store.ts b/src-ts/tools/learn/learn-lib/user-certifications-provider/user-certifications-functions/user-certification-progress.store.ts index f570da701..bcda5e513 100755 --- a/src-ts/tools/learn/learn-lib/user-certifications-provider/user-certifications-functions/user-certification-progress.store.ts +++ b/src-ts/tools/learn/learn-lib/user-certifications-provider/user-certifications-functions/user-certification-progress.store.ts @@ -1,3 +1,6 @@ +import { logInfo } from '../../../../../lib' +import { LearnConfig } from '../../../learn-config' +import { getUserCertificateUrl } from '../../../learn.routes' import { learnUrlGet, learnXhrGetAsync, learnXhrPostAsync, learnXhrPutAsync } from '../../functions' import { LearnUserCertificationProgress } from './learn-user-certification-progress.model' @@ -5,6 +8,29 @@ import { UserCertificationUpdateProgressActions } from './user-certification-upd const certProgressPath: string = 'certification-progresses' +export function completeCourse( + certificationProgressId: string, + certification: string, + handle: string, + provider: string, +): Promise { + + // construct the certificate params + const certificateElement: string = `[${LearnConfig.CERT_ELEMENT_SELECTOR.attribute}=${LearnConfig.CERT_ELEMENT_SELECTOR.value}]` + const certificateUrl: string = getUserCertificateUrl(provider, certification, handle) + + logInfo(`Completing course w certificate URL = ${certificateUrl}`) + + return updateAsync( + certificationProgressId, + UserCertificationUpdateProgressActions.completeCertificate, + { + certificateElement, + certificateUrl, + } + ) +} + export function getAsync(userId: number, provider?: string, certification?: string): Promise> { const params: string = [ diff --git a/src-ts/tools/learn/learn-lib/wave-hero/WaveHero.module.scss b/src-ts/tools/learn/learn-lib/wave-hero/WaveHero.module.scss index 996cb752a..7fb19c5aa 100755 --- a/src-ts/tools/learn/learn-lib/wave-hero/WaveHero.module.scss +++ b/src-ts/tools/learn/learn-lib/wave-hero/WaveHero.module.scss @@ -19,7 +19,7 @@ } } } - + &-inner { padding: calc($space-xxxxl + $space-sm) 0 $space-lg; @include contentWidth; @@ -47,13 +47,13 @@ flex-direction: column; } } - + &-card-col { flex: 0 0 auto; } - + &-text { @extend .body-medium-normal; - margin-top: $space-xxl; + margin-top: $space-sm; } } diff --git a/src-ts/tools/learn/learn-lib/wave-hero/WaveHero.tsx b/src-ts/tools/learn/learn-lib/wave-hero/WaveHero.tsx index a78e17b7b..907111c88 100755 --- a/src-ts/tools/learn/learn-lib/wave-hero/WaveHero.tsx +++ b/src-ts/tools/learn/learn-lib/wave-hero/WaveHero.tsx @@ -7,7 +7,7 @@ interface WaveHeroProps { children?: ReactNode text: string theme?: 'light' - title: string + title: ReactNode } const WaveHero: FC = (props: WaveHeroProps) => { diff --git a/src-ts/tools/learn/learn.routes.tsx b/src-ts/tools/learn/learn.routes.tsx index a2ecb9342..c5c3548ed 100644 --- a/src-ts/tools/learn/learn.routes.tsx +++ b/src-ts/tools/learn/learn.routes.tsx @@ -5,15 +5,30 @@ import { CourseCompletedPage } from './course-completed' import { CourseDetailsPage } from './course-details' import { FreeCodeCamp } from './free-code-camp' import { default as Learn, toolTitle } from './Learn' +import { LearnConfig } from './learn-config' import { MyLearning } from './my-learning' import { WelcomePage } from './welcome' +export enum LEARN_PATHS { + certificate = '/certificate', + completed = '/learn/completed', + myCertificate = '/learn/my-certificate', + myLearning = '/learn/my-learning', + fcc = '/learn/fcc', + root = '/learn', + startCourseRouteFlag = 'start-course', +} + +export function getAuthenticateAndStartCourseRoute(): string { + return `${authUrlLogin()}${encodeURIComponent(`?${LEARN_PATHS.startCourseRouteFlag}`)}` +} + export function getCoursePath(provider: string, certification: string): string { return `${rootRoute}/${provider}/${certification}` } export function getCertificatePath(provider: string, certification: string): string { - return `${getCoursePath(provider, certification)}/certificate` + return `${getCoursePath(provider, certification)}${LEARN_PATHS.certificate}` } export function getCertificationCompletedPath(provider: string, certification: string): string { @@ -40,17 +55,12 @@ export function getLessonPathFromModule( return `${getCoursePath(provider, certification)}/${module}/${lesson}` } -export enum LEARN_PATHS { - completed = '/learn/completed', - myCertificate = '/learn/my-certificate', - myLearning = '/learn/my-learning', - fcc = '/learn/fcc', - root = '/learn', - startCourseRouteFlag = 'start-course', +export function getUserCertificateSsr(provider: string, certification: string, handle: string, title: string): string { + return `${LearnConfig.CERT_DOMAIN}/${handle}/${provider}/${certification}/${encodeURI(title)}` } -export function getAuthenticateAndStartCourseRoute(): string { - return `${authUrlLogin()}${encodeURIComponent(`?${LEARN_PATHS.startCourseRouteFlag}`)}` +export function getUserCertificateUrl(provider: string, certification: string, handle: string): string { + return `${window.location.origin}${getCoursePath(provider, certification)}/${handle}${LEARN_PATHS.certificate}` } export const rootRoute: string = LEARN_PATHS.root diff --git a/src-ts/tools/learn/welcome/WelcomePage.module.scss b/src-ts/tools/learn/welcome/WelcomePage.module.scss index c00834ead..0a9e91b2f 100644 --- a/src-ts/tools/learn/welcome/WelcomePage.module.scss +++ b/src-ts/tools/learn/welcome/WelcomePage.module.scss @@ -1,10 +1,15 @@ @import '../../../lib/styles/includes'; .hero-wrap { + svg:global(.tca-logo) { + margin-bottom: $space-xxl; + max-width: calc(100% - 70px); + } + :global(.hero-card-col) { width: 43.5%; max-width: 600px; - + @include ltemd { width: 100%; max-width: none; @@ -15,35 +20,8 @@ .courses-section { padding: $space-xxxxl 0; position: relative; - + @include ltemd { padding-top: $space-xxl; } } - -.courses-list { - margin-top: $space-lg; - - flex-wrap: wrap; - gap: $space-lg; - display: grid; - grid-template-columns: repeat(4, 1fr); - - // NOTE: these are not app defined breakpoints - // but they are required for the grid to look at it's best - @media (max-width: 1200px) { - grid-template-columns: repeat(3, 1fr); - } - - @include ltelg { - grid-template-columns: repeat(2, 1fr); - } - - @media (max-width: 576px) { - grid-template-columns: repeat(1, 1fr); - } - - @include ltemd { - margin-top: $space-xxl; - } -} \ No newline at end of file diff --git a/src-ts/tools/learn/welcome/WelcomePage.tsx b/src-ts/tools/learn/welcome/WelcomePage.tsx index 12008470f..ac36d3356 100644 --- a/src-ts/tools/learn/welcome/WelcomePage.tsx +++ b/src-ts/tools/learn/welcome/WelcomePage.tsx @@ -11,8 +11,9 @@ import { WaveHero, } from '../learn-lib' -import { CoursesCard } from './courses-card' +import { AvailableCoursesList } from './available-courses-list' import { ProgressBlock } from './progress-block' +import { ReactComponent as TcAcademyFullLogoSvg } from './tca-full-logo.svg' import styles from './WelcomePage.module.scss' const WelcomePage: FC<{}> = () => { @@ -30,7 +31,12 @@ const WelcomePage: FC<{}> = () => {
    + + Welcome! + + )} text={` The Topcoder Academy will provide you with learning opportunities in the form of guided learning paths. @@ -52,23 +58,14 @@ const WelcomePage: FC<{}> = () => {
    - -

    Courses Available

    - {coursesReady && ( -
    - {allCertsData.certifications - .map((certification) => ( - - ))} -
    + )}
    diff --git a/src-ts/tools/learn/welcome/available-courses-list/AvailableCoursesList.module.scss b/src-ts/tools/learn/welcome/available-courses-list/AvailableCoursesList.module.scss new file mode 100644 index 000000000..1294fd777 --- /dev/null +++ b/src-ts/tools/learn/welcome/available-courses-list/AvailableCoursesList.module.scss @@ -0,0 +1,96 @@ +@import '../../../../lib/styles/includes'; + +.courses-list { + flex-wrap: wrap; + gap: $space-lg; + display: grid; + grid-template-columns: repeat(4, 1fr); + margin-bottom: $space-xxl; + + // NOTE: these are not app defined breakpoints + // but they are required for the grid to look at it's best + @media (max-width: 1200px) { + grid-template-columns: repeat(3, 1fr); + } + + @include ltelg { + grid-template-columns: repeat(2, 1fr); + } + + @media (max-width: 576px) { + grid-template-columns: repeat(1, 1fr); + } + + + .courses-group-title { + margin-top: $space-mxx; + + @include ltemd { + margin-top: $space-xxxxl; + } + } +} + +.courses-group-title { + margin-top: $space-xxl; + padding: $space-xxl 0; + border-top: $border solid $black-10; + + @include ltemd { + padding: $space-lg 0; + } +} + +.courses-list-header { + display: flex; + align-items: center; + + > h3 { + display: flex; + align-items: center; + gap: $space-sm; + } + + @include ltemd { + flex-direction: column; + align-items: flex-start; + gap: $space-xxl; + } +} + +.badge { + font-family: $font-roboto; + background: $blue-100; + + padding: 0 $space-sm; + border-radius: 50px; + color: $tc-white; +} + +.courses-list-filters { + display: flex; + margin-left: auto; + + gap: $space-xxl; + + > * { + min-width: 326px; + } + + > :global(.input-wrapper) { + width: 100%; + + > :global(.input-el) { + margin: 0; + } + } + + @include ltelg { + flex-direction: column; + align-items: flex-start; + gap: $space-lg; + } + + @include ltemd { + width: 100%; + } +} diff --git a/src-ts/tools/learn/welcome/available-courses-list/AvailableCoursesList.tsx b/src-ts/tools/learn/welcome/available-courses-list/AvailableCoursesList.tsx new file mode 100644 index 000000000..89c6bc0b9 --- /dev/null +++ b/src-ts/tools/learn/welcome/available-courses-list/AvailableCoursesList.tsx @@ -0,0 +1,102 @@ +import classNames from 'classnames' +import { Dictionary, groupBy, identity, orderBy } from 'lodash' +import { Dispatch, FC, Fragment, SetStateAction, useMemo } from 'react' + +import { InputSelect, useLocalStorage } from '../../../../lib' +import { LearnCertification, UserCertificationCompleted, UserCertificationInProgress } from '../../learn-lib' +import { CoursesCard } from '../courses-card' + +import styles from './AvailableCoursesList.module.scss' + +interface AvailableCoursesListProps { + certifications: ReadonlyArray + userCompletedCertifications: ReadonlyArray + userInProgressCertifications: ReadonlyArray +} + +const PRIORITY_CATEGORIES: ReadonlyArray = [ + 'Data Science', + 'Web Development', +] + +const AvailableCoursesList: FC = (props: AvailableCoursesListProps) => { + + const [selectedCategory, setSelectedCategory]: [ + string, + Dispatch> + ] = useLocalStorage('tca-welcome-filter-certs', '') + + // certificates indexed by category, sorted by title + const certsByCategory: Dictionary> = useMemo(() => { + return groupBy(orderBy(props.certifications, 'title', 'asc'), 'category') + }, [props.certifications]) + + // compute all the available category dropdown options + const certsCategoriesOptions: Array<{ + label: string, + value: string, + }> = useMemo(() => { + return [ + {label: 'All Categories', value: '', orderIndex: -1}, + ...Object.keys(certsByCategory).sort().map((c) => ({ + label: c, + value: c, + })), + ] + }, [certsByCategory]) + + // create and sort the certificates groups + const certificationsGroups: Array = useMemo(() => { + return orderBy(Object.keys(certsByCategory), [ + c => PRIORITY_CATEGORIES.includes(c) ? -1 : 1, + identity, + ], ['asc', 'asc']) + }, [certsByCategory]) + + const certificationsCount: number = (certsByCategory[selectedCategory] ?? props.certifications).length + + return ( +
    +
    +

    + Courses Available + + {certificationsCount} + +

    + +
    + setSelectedCategory(e.target.value as string)} + name='filter-courses' + label='Categories' + > +
    +
    + + {certificationsGroups.map((category) => (!selectedCategory || selectedCategory === category) && ( + +

    + {category} +

    + +
    + {certsByCategory[category] + .map((certification) => ( + + ))} +
    +
    + ))} +
    + ) +} + +export default AvailableCoursesList diff --git a/src-ts/tools/learn/welcome/available-courses-list/index.ts b/src-ts/tools/learn/welcome/available-courses-list/index.ts new file mode 100644 index 000000000..e11c8279c --- /dev/null +++ b/src-ts/tools/learn/welcome/available-courses-list/index.ts @@ -0,0 +1 @@ +export { default as AvailableCoursesList } from './AvailableCoursesList' diff --git a/src-ts/tools/learn/welcome/courses-card/CoursesCard.tsx b/src-ts/tools/learn/welcome/courses-card/CoursesCard.tsx index 29c461d0a..2ceaabb38 100644 --- a/src-ts/tools/learn/welcome/courses-card/CoursesCard.tsx +++ b/src-ts/tools/learn/welcome/courses-card/CoursesCard.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames' import { Dispatch, FC, SetStateAction, useEffect, useState } from 'react' -import { Button } from '../../../../lib' +import { Button, ButtonStyle } from '../../../../lib' import { CourseTitle, LearnCertification, @@ -20,6 +20,8 @@ interface CoursesCardProps { const CoursesCard: FC = (props: CoursesCardProps) => { + const [buttonStyle, setButtonStyle]: [ButtonStyle, Dispatch>] + = useState('primary') const [buttonLabel, setButtonLabel]: [string, Dispatch>] = useState('') const [link, setLink]: [string, Dispatch>] @@ -42,6 +44,7 @@ const CoursesCard: FC = (props: CoursesCardProps) => { if (isCompleted) { // if the course is completed, View the Certificate + setButtonStyle('secondary') setButtonLabel('View Certificate') setLink(getCertificatePath( props.certification.providerName, @@ -58,6 +61,7 @@ const CoursesCard: FC = (props: CoursesCardProps) => { } else { // otherwise this course is in-progress, so Resume the course at the next lesson + setButtonStyle('secondary') setButtonLabel('Resume') setLink(getLessonPathFromCurrentLesson( props.certification.providerName, @@ -85,7 +89,7 @@ const CoursesCard: FC = (props: CoursesCardProps) => {
    {!!link && (