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..46f16f8c4 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,6 @@ 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-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-wrapper/InputWrapper.module.scss b/src-ts/lib/form/form-groups/form-input/input-wrapper/InputWrapper.module.scss index 6b212bb53..4052802ad 100644 --- a/src-ts/lib/form/form-groups/form-input/input-wrapper/InputWrapper.module.scss +++ b/src-ts/lib/form/form-groups/form-input/input-wrapper/InputWrapper.module.scss @@ -30,6 +30,7 @@ $error-line-height: 14px; box-sizing: border-box; border-radius: $space-xs; margin-bottom: calc($error-line-height + $space-xs); + position: relative; &.rating { border-color: transparent; diff --git a/src-ts/lib/form/form-groups/form-input/input-wrapper/InputWrapper.tsx b/src-ts/lib/form/form-groups/form-input/input-wrapper/InputWrapper.tsx index dff63ca4d..f3468cb49 100644 --- a/src-ts/lib/form/form-groups/form-input/input-wrapper/InputWrapper.tsx +++ b/src-ts/lib/form/form-groups/form-input/input-wrapper/InputWrapper.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames' -import { Dispatch, FC, ReactNode, SetStateAction, useState } from 'react' +import { Dispatch, forwardRef, ForwardRefExoticComponent, ReactNode, SetStateAction, useState } from 'react' import { IconSolid } from '../../../../svgs' @@ -16,11 +16,11 @@ interface InputWrapperProps { readonly hideInlineErrors?: boolean readonly hint?: string readonly label: string | JSX.Element - readonly tabIndex: number + readonly tabIndex?: number readonly type: 'checkbox' | 'password' | 'rating' | 'text' | 'textarea' } -const InputWrapper: FC = (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/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/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/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..902cfedea 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 @@ -1,9 +1,9 @@ import { LearnCertification } from './all-certifications-functions' export interface AllCertificationsProviderData { + allCertifications: Array 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..65ace83d9 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 @@ -1,10 +1,23 @@ -import { Dispatch, SetStateAction, useEffect, useState } from 'react' +import { filter as filterBy, orderBy } from 'lodash' +import { Dispatch, MutableRefObject, SetStateAction, useEffect, useRef, useState } from 'react' import { allCertificationsGetAsync, LearnCertification } from './all-certifications-functions' import { AllCertificationsProviderData } from './all-certifications-provider-data.model' +interface CertificationsAllProviderSortOptions { + direction: 'asc'|'desc', + field: keyof LearnCertification +} + +interface CertificationsAllProviderFilterOptions { + field: keyof LearnCertification, + value: string +} + interface CertificationsAllProviderOptions { enabled?: boolean + filter?: CertificationsAllProviderFilterOptions + sort?: CertificationsAllProviderSortOptions } export function useAllCertifications( @@ -12,16 +25,60 @@ export function useAllCertifications( certificationId?: string, options?: CertificationsAllProviderOptions ): AllCertificationsProviderData { + const sort: MutableRefObject = useRef(options?.sort) + const filter: MutableRefObject = useRef(options?.filter) const [state, setState]: [AllCertificationsProviderData, Dispatch>] = useState({ + allCertifications: [], certifications: [], - certificationsCount: 0, loading: false, ready: false, }) + function getSortedCertifications( + certificates: Array + ): Array { + return !sort.current + ? certificates + : orderBy([...certificates], sort.current.field, sort.current.direction) + } + + function getFilteredCertifications( + certificates: Array + ): Array { + return !filter.current?.value + ? certificates + : filterBy([...certificates], {[filter.current.field]: filter.current.value}) + } + + function getFilteredAndSortedCertifications( + certificates: Array + ): Array { + return getSortedCertifications(getFilteredCertifications(certificates)) + } + + if (sort.current?.direction !== options?.sort?.direction || sort.current?.field !== options?.sort?.field) { + sort.current = options?.sort ? { ...options?.sort } : undefined + + // wait to exit current render loop before triggering a new state update + setTimeout(() => setState((prevState) => ({ + ...prevState, + certifications: getFilteredAndSortedCertifications(prevState.allCertifications), + }))) + } + + if (filter.current?.field !== options?.filter?.field || filter.current?.value !== options?.filter?.value) { + filter.current = options?.filter ? { ...options?.filter } : undefined + + // wait to exit current render loop before triggering a new state update + setTimeout(() => setState((prevState) => ({ + ...prevState, + certifications: getFilteredAndSortedCertifications(prevState.allCertifications), + }))) + } + useEffect(() => { setState((prevState) => ({ ...prevState, @@ -34,23 +91,14 @@ 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], - } + const filteredCertifications: Array = getFilteredCertifications(certifications) + const sortedCertifications: Array = getSortedCertifications(filteredCertifications) + setState((prevState) => ({ ...prevState, - ...certs, - certificationsCount: certifications.length, + allCertifications: certificationId ? [] : [...certifications], + certification: !certificationId ? undefined : certifications as unknown as LearnCertification, + certifications: certificationId ? [] : sortedCertifications, loading: false, ready: true, })) diff --git a/src-ts/tools/learn/welcome/WelcomePage.module.scss b/src-ts/tools/learn/welcome/WelcomePage.module.scss index c44175908..8ffaf1027 100644 --- a/src-ts/tools/learn/welcome/WelcomePage.module.scss +++ b/src-ts/tools/learn/welcome/WelcomePage.module.scss @@ -52,3 +52,58 @@ margin-top: $space-xxl; } } + +.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/WelcomePage.tsx b/src-ts/tools/learn/welcome/WelcomePage.tsx index 303472002..c4d0be442 100644 --- a/src-ts/tools/learn/welcome/WelcomePage.tsx +++ b/src-ts/tools/learn/welcome/WelcomePage.tsx @@ -1,10 +1,12 @@ import classNames from 'classnames' -import { FC } from 'react' +import { uniq } from 'lodash' +import { Dispatch, FC, SetStateAction, useMemo } from 'react' -import { ContentLayout, LoadingSpinner, Portal } from '../../../lib' +import { ContentLayout, InputSelect, LoadingSpinner, Portal, useLocalStorage } from '../../../lib' import '../../../lib/styles/index.scss' import { AllCertificationsProviderData, + LearnCertification, useAllCertifications, UserCertificationsProviderData, useUserCertifications, @@ -16,13 +18,52 @@ import { ProgressBlock } from './progress-block' import { ReactComponent as TcAcademyFullLogoSvg } from './tca-full-logo.svg' import styles from './WelcomePage.module.scss' +type SORT_FIELD_TYPE = keyof LearnCertification +const SORT_OPTIONS: Array<{label: string, value: SORT_FIELD_TYPE}> = [ + {label: 'Category', value: 'category'}, + {label: 'Newest', value: 'createdAt'}, + {label: 'Title', value: 'title'}, +] +export const DEFAULT_SORT: SORT_FIELD_TYPE = SORT_OPTIONS[0].value + const WelcomePage: FC<{}> = () => { - const allCertsData: AllCertificationsProviderData = useAllCertifications() + const [sortField, setSortField]: [ + SORT_FIELD_TYPE, + Dispatch> + ] = useLocalStorage('tca-welcome-sort-certs', DEFAULT_SORT) + + const [selectedCategory, setSelectedCategory]: [ + string, + Dispatch> + ] = useLocalStorage('tca-welcome-filter-certs', '') + + const allCertsData: AllCertificationsProviderData = useAllCertifications( + undefined, + undefined, + { + filter: { + field: 'category', + value: selectedCategory, + }, + sort: { + direction: sortField === 'createdAt' ? 'desc' : 'asc', + field: sortField, + }, + } + ) const userCertsData: UserCertificationsProviderData = useUserCertifications() const coursesReady: boolean = allCertsData.ready && userCertsData.ready + const certsCategoriesOptions: Array<{label: string, value: string}> = useMemo(() => { + const certsCategories: Array = uniq(allCertsData.allCertifications.map(c => c.category)) + return [ + {label: 'All Categories', value: ''}, + ...certsCategories.map((c) => ({value: c, label: c})), + ] + }, [allCertsData]) + return ( @@ -48,7 +89,7 @@ const WelcomePage: FC<{}> = () => { theme='light' > = () => {
-

Courses Available

+
+

+ Courses Available + + {allCertsData.certifications.length} + +

+ +
+ setSelectedCategory(e.target.value as string)} + name='filter-courses' + label='Categories' + > + setSortField(e.target.value as SORT_FIELD_TYPE)} + name='sort-courses' + label='Sort by' + > +
+
{coursesReady && (
{allCertsData.certifications - .map((certification) => ( + .map((certification, i) => ( 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 && (