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 index f9da6acf8..bf4ed58c6 100644 --- 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 @@ -30,7 +30,7 @@ interface InputSelectProps { readonly label?: string readonly name: string readonly onChange: (event: ChangeEvent) => void - readonly options: Array + readonly options: ReadonlyArray readonly tabIndex?: number readonly value?: string } @@ -68,7 +68,7 @@ const InputSelect: FC = (props: InputSelectProps) => { hideInlineErrors={props.hideInlineErrors} ref={triggerRef} > -
+
!props.disabled && toggleMenu}> {selectedOption ? label(selectedOption) : ''} 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 index 605f28a24..fcd5c8276 100644 --- 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 @@ -1 +1,2 @@ +export * from './InputSelect' 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 bb0a843be..e94b67104 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 @@ -78,6 +78,7 @@ $error-line-height: 14px; background-color: $black-10; background: $black-10; border-color: $black-40; + pointer-events: none; } &.input-error { diff --git a/src-ts/tools/learn/learn-lib/my-course-card/completed/Completed.tsx b/src-ts/tools/learn/learn-lib/my-course-card/completed/Completed.tsx index a9f1434de..805f02df4 100644 --- a/src-ts/tools/learn/learn-lib/my-course-card/completed/Completed.tsx +++ b/src-ts/tools/learn/learn-lib/my-course-card/completed/Completed.tsx @@ -1,3 +1,4 @@ +import classNames from 'classnames' import { FC } from 'react' import { Button, textFormatDateLocaleShortString } from '../../../../../lib' @@ -18,7 +19,7 @@ const Completed: FC = (props: CompletedProps) => { } return ( -
+
= (props: InProgressProps) => { } return ( -
+
* { - flex: 0 1 calc(50% - calc($space-xxl / 2)); - } - - @include ltemd { - > * { - flex: 1 1 0; - } - } -} - .loading-spinner { background: none; -} \ No newline at end of file +} diff --git a/src-ts/tools/learn/my-learning/MyLearning.tsx b/src-ts/tools/learn/my-learning/MyLearning.tsx index 115f63ce6..b97350f30 100755 --- a/src-ts/tools/learn/my-learning/MyLearning.tsx +++ b/src-ts/tools/learn/my-learning/MyLearning.tsx @@ -1,12 +1,9 @@ -import { FC, useContext, useMemo } from 'react' +import { Dispatch, FC, ReactNode, SetStateAction, useContext, useMemo, useState } from 'react' import { Breadcrumb, BreadcrumbItemModel, ContentLayout, LoadingSpinner, Portal, profileContext, ProfileContextData } from '../../../lib' import { AllCertificationsProviderData, LearnCertification, - LearningHat, - MyCourseCompletedCard, - MyCourseInProgressCard, useAllCertifications, useLearnBreadcrumb, UserCertificationsProviderData, @@ -15,7 +12,10 @@ import { } from '../learn-lib' import { LEARN_PATHS } from '../learn.routes' +import { CompletedTab } from './completed-tab' import { HeroCard } from './hero-card' +import { InProgressTab } from './in-progress-tab' +import { MyTabsNavbar, MyTabsViews } from './my-tabs-navbar' import styles from './MyLearning.module.scss' interface CertificatesByIdType { @@ -27,6 +27,7 @@ const MyLearning: FC<{}> = () => { const { profile, initialized: profileReady }: ProfileContextData = useContext(profileContext) const { completed, inProgress, ready: coursesReady }: UserCertificationsProviderData = useUserCertifications() const { certifications, ready: certificatesReady }: AllCertificationsProviderData = useAllCertifications() + const [activeTab, setActiveTab]: [MyTabsViews|undefined, Dispatch>] = useState() const ready: boolean = profileReady && coursesReady && certificatesReady @@ -44,6 +45,28 @@ const MyLearning: FC<{}> = () => { }, ]) + const renderTabs: () => ReactNode = () => ( + + {activeTab === MyTabsViews.completed ? ( + + ) : ( + + )} + + ) + return ( @@ -54,6 +77,7 @@ const MyLearning: FC<{}> = () => {
= () => {
- {ready && ( - <> -
- {inProgress.map((certif) => ( - - ))} -
- - {!!completed.length && ( -
-
- -

Completed Courses

-
- -
- {completed.map((certif) => ( - - ))} -
-
- )} - - )} + {ready && renderTabs()}
) diff --git a/src-ts/tools/learn/my-learning/completed-tab/CompletedTab.module.scss b/src-ts/tools/learn/my-learning/completed-tab/CompletedTab.module.scss new file mode 100644 index 000000000..e3f93db9c --- /dev/null +++ b/src-ts/tools/learn/my-learning/completed-tab/CompletedTab.module.scss @@ -0,0 +1,30 @@ +@import '../../../../lib/styles/includes'; + +.cards-wrap { + display: flex; + gap: $space-xxl; + flex-wrap: wrap; + + > * { + flex: 0 1 calc(50% - calc($space-xxl / 2)); + } + + > :global(.course-card-wrap) { + background: $black-5; + } + + @include ltemd { + > * { + flex: 1 1 0; + } + } +} + +.placeholder-wrap { + flex: 1 1 auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $space-xxl; +} diff --git a/src-ts/tools/learn/my-learning/completed-tab/CompletedTab.tsx b/src-ts/tools/learn/my-learning/completed-tab/CompletedTab.tsx new file mode 100644 index 000000000..143cfe380 --- /dev/null +++ b/src-ts/tools/learn/my-learning/completed-tab/CompletedTab.tsx @@ -0,0 +1,72 @@ +import { FC, ReactNode } from 'react' + +import { Button } from '../../../../lib' +import { LearnCertification, MyCourseCompletedCard, UserCertificationCompleted } from '../../learn-lib' +import { LEARN_PATHS } from '../../learn.routes' +import { sortOptions } from '../my-learning-sort-options' +import { MyTabsViews } from '../my-tabs-navbar' +import { TabContentLayout } from '../tab-content-layout' +import { useSortAndFilter, UseSortAndFilterValue } from '../use-sort-and-filter' + +import styles from './CompletedTab.module.scss' + +interface CompletedTabProps { + allCertificates: ReadonlyArray + certificatesById: {[key: string]: LearnCertification} + certifications: ReadonlyArray +} + +const CompletedTab: FC = (props: CompletedTabProps) => { + + const { + handleCategoryChange, + certifications, + handleSortChange, + }: UseSortAndFilterValue = useSortAndFilter( + props.allCertificates, + props.certifications + ) + + const hasCertifications: boolean = certifications.length >= 1 + + const renderPlaceholder: () => ReactNode = () => ( +
+
+ Your Completed courses will live here. Let’s go! +
+
+ ) + + const renderCertificationsList: () => ReactNode = () => ( + hasCertifications ? certifications.map((certif) => ( + + )) : renderPlaceholder() + ) + + return ( + +
+ {renderCertificationsList()} +
+
+ ) +} + +export default CompletedTab diff --git a/src-ts/tools/learn/my-learning/completed-tab/index.ts b/src-ts/tools/learn/my-learning/completed-tab/index.ts new file mode 100644 index 000000000..954a7e44f --- /dev/null +++ b/src-ts/tools/learn/my-learning/completed-tab/index.ts @@ -0,0 +1 @@ +export { default as CompletedTab } from './CompletedTab' diff --git a/src-ts/tools/learn/my-learning/in-progress-tab/InProgressTab.module.scss b/src-ts/tools/learn/my-learning/in-progress-tab/InProgressTab.module.scss new file mode 100644 index 000000000..82ed3dde8 --- /dev/null +++ b/src-ts/tools/learn/my-learning/in-progress-tab/InProgressTab.module.scss @@ -0,0 +1,25 @@ +@import '../../../../lib/styles/includes'; + +.wrap { + gap: $space-lg; + display: flex; + flex-direction: column; + position: relative; + + @include ltemd { + gap: $space-xxl; + } + + > :global(.course-card-wrap) { + background: $black-5; + } +} + +.placeholder-wrap { + flex: 1 1 auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: $space-xxl; +} diff --git a/src-ts/tools/learn/my-learning/in-progress-tab/InProgressTab.tsx b/src-ts/tools/learn/my-learning/in-progress-tab/InProgressTab.tsx new file mode 100644 index 000000000..53c03c2cb --- /dev/null +++ b/src-ts/tools/learn/my-learning/in-progress-tab/InProgressTab.tsx @@ -0,0 +1,75 @@ +import { FC, ReactNode } from 'react' + +import { Button } from '../../../../lib' +import { LearnCertification, MyCourseInProgressCard, UserCertificationInProgress } from '../../learn-lib' +import { LEARN_PATHS } from '../../learn.routes' +import { sortOptions } from '../my-learning-sort-options' +import { MyTabsViews } from '../my-tabs-navbar' +import { TabContentLayout } from '../tab-content-layout' +import { useSortAndFilter, UseSortAndFilterValue } from '../use-sort-and-filter' + +import styles from './InProgressTab.module.scss' + +interface InProgressTabProps { + allCertificates: ReadonlyArray + certificatesById: {[key: string]: LearnCertification} + certifications: ReadonlyArray +} + +const InProgressTab: FC = (props: InProgressTabProps) => { + + const { + handleCategoryChange, + certifications, + handleSortChange, + }: UseSortAndFilterValue = useSortAndFilter( + props.allCertificates, + props.certifications + ) + + const hasCertifications: boolean = certifications.length >= 1 + + const renderPlaceholder: () => ReactNode = () => ( +
+
+ Your In Progress courses will live here. Let’s go! +
+
+ ) + + const renderCertificationsList: () => ReactNode = () => ( + hasCertifications ? certifications.map((certif) => ( + + )) : renderPlaceholder() + ) + + return ( + +
+ {renderCertificationsList()} +
+
+ ) +} + +export default InProgressTab diff --git a/src-ts/tools/learn/my-learning/in-progress-tab/index.ts b/src-ts/tools/learn/my-learning/in-progress-tab/index.ts new file mode 100644 index 000000000..93afb5922 --- /dev/null +++ b/src-ts/tools/learn/my-learning/in-progress-tab/index.ts @@ -0,0 +1 @@ +export { default as InProgressTab } from './InProgressTab' diff --git a/src-ts/tools/learn/my-learning/my-learning-sort-options.ts b/src-ts/tools/learn/my-learning/my-learning-sort-options.ts new file mode 100644 index 000000000..dfbe394e1 --- /dev/null +++ b/src-ts/tools/learn/my-learning/my-learning-sort-options.ts @@ -0,0 +1,26 @@ +export interface SortOption { + label: string, + value: string, +} + +const COMPLETED_SORT_OPTIONS: ReadonlyArray = [ + {label: 'Completion date: new to old', value: '-completedDate'}, + {label: 'Completion date: old to new', value: 'completedDate'}, +] + +const IN_PROGRESS_SORT_OPTIONS: ReadonlyArray = [ + {label: 'Recent activity: new to old', value: '-updatedAt'}, + {label: 'Recent activity: old to new', value: 'updatedAt'}, + {label: 'Most progress', value: '-courseProgressPercentage'}, + {label: 'Least progress', value: 'courseProgressPercentage'}, + {label: 'Name', value: 'certification'}, + // {label: 'Length', field: (c: ), direction: 'asc'}, +] + +export const sortOptions: { + completed: ReadonlyArray, + inProgress: ReadonlyArray +} = { + completed: COMPLETED_SORT_OPTIONS, + inProgress: IN_PROGRESS_SORT_OPTIONS, +} diff --git a/src-ts/tools/learn/my-learning/my-tabs-navbar/MyTabsNavbar.module.scss b/src-ts/tools/learn/my-learning/my-tabs-navbar/MyTabsNavbar.module.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src-ts/tools/learn/my-learning/my-tabs-navbar/MyTabsNavbar.tsx b/src-ts/tools/learn/my-learning/my-tabs-navbar/MyTabsNavbar.tsx new file mode 100644 index 000000000..789dcf9ea --- /dev/null +++ b/src-ts/tools/learn/my-learning/my-tabs-navbar/MyTabsNavbar.tsx @@ -0,0 +1,47 @@ +import { Dispatch, FC, ReactNode, SetStateAction, useEffect, useMemo, useState } from 'react' + +import { TabsNavbar, TabsNavItem } from '../../../../lib' + +import { getMyTabsNavbarTabs, MyTabsViews } from './tabs-navbar.config' + +interface MyTabsNavbarProps { + children: ReactNode + completed: number + inProgress: number + onTabChange: (activeTab: MyTabsViews) => void +} + +const MyTabsNavbar: FC = (props: MyTabsNavbarProps) => { + const [activeTab, setActiveTab]: [MyTabsViews, Dispatch>] = useState( + MyTabsViews.inProgress + ) + + const tabs: ReadonlyArray = useMemo(() => getMyTabsNavbarTabs( + props.completed, + props.inProgress + ), [props.completed, props.inProgress]) + + const handleOnChange: (evTab: string) => void = (evTab: string) => { + const tab: MyTabsViews = evTab as MyTabsViews + setActiveTab(tab) + props.onTabChange(tab) + } + + useEffect(() => { + props.onTabChange(activeTab) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.onTabChange]) + + return ( + <> + + {props.children} + + ) +} + +export default MyTabsNavbar diff --git a/src-ts/tools/learn/my-learning/my-tabs-navbar/index.tsx b/src-ts/tools/learn/my-learning/my-tabs-navbar/index.tsx new file mode 100644 index 000000000..1e9b05c55 --- /dev/null +++ b/src-ts/tools/learn/my-learning/my-tabs-navbar/index.tsx @@ -0,0 +1,2 @@ +export { default as MyTabsNavbar } from './MyTabsNavbar' +export * from './tabs-navbar.config' diff --git a/src-ts/tools/learn/my-learning/my-tabs-navbar/tabs-navbar.config.ts b/src-ts/tools/learn/my-learning/my-tabs-navbar/tabs-navbar.config.ts new file mode 100644 index 000000000..a896c44e5 --- /dev/null +++ b/src-ts/tools/learn/my-learning/my-tabs-navbar/tabs-navbar.config.ts @@ -0,0 +1,29 @@ +import { TabsNavItem } from '../../../../lib' + +export enum MyTabsViews { + inProgress = 'Courses In Progress', + completed = 'Completed Courses', +} + +export const getMyTabsNavbarTabs: ( + completed: number, + inProgress: number +) => ReadonlyArray = ( + completed: number, + inProgress: number +) => [ + { + badges: [ + {count: inProgress, type: 'info'}, + ], + id: MyTabsViews.inProgress, + title: MyTabsViews.inProgress, + }, + { + badges: [ + {count: completed, type: 'info'}, + ], + id: MyTabsViews.completed, + title: MyTabsViews.completed, + }, +] diff --git a/src-ts/tools/learn/my-learning/tab-content-layout/TabContentLayout.module.scss b/src-ts/tools/learn/my-learning/tab-content-layout/TabContentLayout.module.scss new file mode 100644 index 000000000..7e133a004 --- /dev/null +++ b/src-ts/tools/learn/my-learning/tab-content-layout/TabContentLayout.module.scss @@ -0,0 +1,68 @@ +@import '../../../../lib/styles/includes'; + +.wrap { + padding: $space-xxxxl 0; + gap: $space-lg; + display: flex; + flex-direction: column; + position: relative; + + flex: 1 1 auto; + + &:empty { + display: none; + } + + @include ltemd { + gap: $space-xxl; + } +} + +.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%; + > * { + min-width: auto; + } + } +} + +.title-line { + 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; + } +} diff --git a/src-ts/tools/learn/my-learning/tab-content-layout/TabContentLayout.tsx b/src-ts/tools/learn/my-learning/tab-content-layout/TabContentLayout.tsx new file mode 100644 index 000000000..8aed257b6 --- /dev/null +++ b/src-ts/tools/learn/my-learning/tab-content-layout/TabContentLayout.tsx @@ -0,0 +1,92 @@ +import { Dictionary, groupBy } from 'lodash' +import { ChangeEvent, ChangeEventHandler, Dispatch, FC, ReactNode, SetStateAction, useEffect, useMemo, useState } from 'react' + +import { InputSelect, useSessionStorage } from '../../../../lib' +import { LearnCertification } from '../../learn-lib' +import { SortOption } from '../my-learning-sort-options' + +import styles from './TabContentLayout.module.scss' + +interface TabContentLayoutProps { + certifications: ReadonlyArray + children: ReactNode + disableFilters?: boolean + onCategoryChange?: (category: string) => void + onSortChange?: (field: string) => void + sortOptions: ReadonlyArray + title: string +} + +const TabContentLayout: FC = (props: TabContentLayoutProps) => { + + const [sortingField, setSortingField]: [ + string, + Dispatch> + ] = useSessionStorage(`my-learn-sort[${props.title}]`, props.sortOptions[0].value) + + const [selectedCategory, setSelectedCategory]: [ + string, + Dispatch> + ] = useState('') + + // compute all the available category dropdown options + const certsCategoriesOptions: Array<{ + label: string, + value: string, + }> = useMemo(() => { + const certsByCategory: Dictionary> = groupBy(props.certifications, 'category') + return [ + {label: 'All Categories', value: ''}, + ...Object.keys(certsByCategory).sort().map((c) => ({ + label: c, + value: c, + })), + ] + }, [props.certifications]) + + const handleCategoryChange: ChangeEventHandler = (e: ChangeEvent) => { + setSelectedCategory(e.target.value) + props.onCategoryChange?.(e.target.value) + } + + const handleSortChange: ChangeEventHandler = (e: ChangeEvent) => { + setSortingField(e.target.value) + props.onSortChange?.(e.target.value) + } + + useEffect(() => { + props.onCategoryChange?.(selectedCategory) + props.onSortChange?.(sortingField) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.onCategoryChange, props.onSortChange]) + + return ( +
+
+

{props.title}

+ +
+ + +
+
+ {props.children} +
+ ) +} + +export default TabContentLayout diff --git a/src-ts/tools/learn/my-learning/tab-content-layout/index.ts b/src-ts/tools/learn/my-learning/tab-content-layout/index.ts new file mode 100644 index 000000000..c430b5485 --- /dev/null +++ b/src-ts/tools/learn/my-learning/tab-content-layout/index.ts @@ -0,0 +1 @@ +export { default as TabContentLayout } from './TabContentLayout' diff --git a/src-ts/tools/learn/my-learning/use-sort-and-filter.tsx b/src-ts/tools/learn/my-learning/use-sort-and-filter.tsx new file mode 100644 index 000000000..f1c0be4f8 --- /dev/null +++ b/src-ts/tools/learn/my-learning/use-sort-and-filter.tsx @@ -0,0 +1,68 @@ +import { groupBy, mapValues, orderBy } from 'lodash' +import { Dispatch, SetStateAction, useMemo, useState } from 'react' + +import { LearnCertification, UserCertificationCompleted, UserCertificationInProgress } from '../learn-lib' + +type MyCertificationsType = ReadonlyArray + +export interface UseSortAndFilterValue { + certifications: MyCertificationsType, + handleCategoryChange: (category: string) => void, + handleSortChange: (field: string) => void, +} + +export const useSortAndFilter: ( + certifications: ReadonlyArray, + myCertifications: ReadonlyArray +) => UseSortAndFilterValue = ( + certifications, + myCertifications +) => { + + const [selectedCategory, setSelectedCategory]: [ + string, + Dispatch> + ] = useState('') + + const [sortingField, setSortingField]: [ + string, + Dispatch> + ] = useState('') + + const [sortingDirection, setSortingDirection]: [ + string, + Dispatch> + ] = useState('asc') + + const handleSortChange: (field: string) => void = (field: string) => { + + setSortingField(field.replace(/^-/, '')) + setSortingDirection(field.startsWith('-') ? 'desc' : 'asc') + } + + const myCertificatesByCategory: {[key: string]: MyCertificationsType} = useMemo(() => { + + const certsById: {[key: string]: LearnCertification} = mapValues(groupBy(certifications, 'id'), ([cert]) => cert) + return groupBy( + myCertifications as MyCertificationsType, + (cert) => certsById[cert.certificationId]?.category + ) + }, [certifications, myCertifications]) + + const myCertificationsFiltered: MyCertificationsType = ( + !selectedCategory ? myCertifications : myCertificatesByCategory[selectedCategory] + ) as MyCertificationsType ?? [] + + const myCertificationsSorted: MyCertificationsType = orderBy( + myCertificationsFiltered, + sortingField, + sortingDirection as 'asc'|'desc' + ) + + return { + certifications: myCertificationsSorted, + handleCategoryChange: setSelectedCategory, + handleSortChange, + + } +}