diff --git a/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx index 738e3918e..334f5f9b0 100644 --- a/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx +++ b/src/apps/profiles/src/member-profile/skills/MemberSkillsInfo.tsx @@ -1,8 +1,9 @@ -import { Dispatch, FC, SetStateAction, useEffect, useMemo, useState } from 'react' +/* eslint-disable complexity */ +import { Dispatch, FC, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { filter, orderBy } from 'lodash' -import { UserProfile, UserSkill, UserSkillDisplayModes } from '~/libs/core' +import { getMemberSkillDetails, UserProfile, UserSkill, UserSkillDisplayModes } from '~/libs/core' import { GroupedSkillsUI, HowSkillsWorkModal, isSkillVerified, SkillPill, useLocalStorage } from '~/libs/shared' import { Button } from '~/libs/ui' @@ -23,6 +24,7 @@ interface MemberSkillsInfoProps { const MemberSkillsInfo: FC = (props: MemberSkillsInfoProps) => { const [queryParams]: [URLSearchParams, any] = useSearchParams() const editMode: string | null = queryParams.get(EDIT_MODE_QUERY_PARAM) + const [canFetchSkillDetails, setCanFetchSkillDetails] = useState(true) const canEdit: boolean = props.authProfile?.handle === props.profile.handle const [hasSeenPrincipalIntro, setHasSeenPrincipalIntro] = useLocalStorage('seen-principal-intro', {} as any) @@ -131,6 +133,15 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp setPrincipalIntroModalVisible(false) } + const fetchSkillDetails = useCallback((skillId: string) => getMemberSkillDetails(props.profile.handle, skillId) + .catch(e => { + if (e.response.status === 403) { + setCanFetchSkillDetails(false) + } + + throw e + }), [props.profile.handle]) + return (
@@ -174,6 +185,7 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp skill={skill} key={skill.id} theme={isSkillVerified(skill) ? 'verified' : 'dark'} + fetchSkillDetails={canFetchSkillDetails ? fetchSkillDetails : undefined} /> ))}
@@ -188,6 +200,7 @@ const MemberSkillsInfo: FC = (props: MemberSkillsInfoProp )}
)} diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts index c3cf12ba6..77be67235 100644 --- a/src/config/environments/default.env.ts +++ b/src/config/environments/default.env.ts @@ -96,6 +96,8 @@ export const STRIPE = { } export const URLS = { + ACADEMY_CERTIFICATION: `https://academy.${TC_DOMAIN}/tca-certifications`, + ACADEMY_COURSE: `https://academy.${TC_DOMAIN}/freeCodeCamp`, ACCOUNT_SETTINGS: `https://account-settings.${TC_DOMAIN}/#account`, CHALLENGES_PAGE: `${TOPCODER_URL}/challenges`, UNIVERSAL_NAV: `https://uni-nav.${TC_DOMAIN}/v1/tc-universal-nav.js`, diff --git a/src/config/environments/global-config.model.ts b/src/config/environments/global-config.model.ts index 019625f96..d20398eab 100644 --- a/src/config/environments/global-config.model.ts +++ b/src/config/environments/global-config.model.ts @@ -39,6 +39,8 @@ export interface GlobalConfig { API_VERSION: string | undefined } URLS: { + ACADEMY_CERTIFICATION: string + ACADEMY_COURSE: string USER_PROFILE: string ACCOUNT_SETTINGS: string UNIVERSAL_NAV: string diff --git a/src/libs/core/lib/profile/profile-functions/profile-store/index.ts b/src/libs/core/lib/profile/profile-functions/profile-store/index.ts index 6210fc38f..258c2c1b9 100644 --- a/src/libs/core/lib/profile/profile-functions/profile-store/index.ts +++ b/src/libs/core/lib/profile/profile-functions/profile-store/index.ts @@ -2,6 +2,7 @@ export { get as profileStoreGet, patchName as profileStorePatchName, getMemberStats, + getMemberSkillDetails, } from './profile-xhr.store' export { diff --git a/src/libs/core/lib/profile/profile-functions/profile-store/profile-xhr.store.ts b/src/libs/core/lib/profile/profile-functions/profile-store/profile-xhr.store.ts index 6201148af..327babf7d 100644 --- a/src/libs/core/lib/profile/profile-functions/profile-store/profile-xhr.store.ts +++ b/src/libs/core/lib/profile/profile-functions/profile-store/profile-xhr.store.ts @@ -7,6 +7,7 @@ import { UpdateProfileRequest, UserPhotoUpdateResponse } from '../../modify-user import { ModifyUserPropertyRequest, ModifyUserPropertyResponse } from '../../modify-user-role.model' import { UserEmailPreferences } from '../../user-email-preference.model' import { UserProfile } from '../../user-profile.model' +import { UserSkillWithActivity } from '../../user-skill.model' import { UserStats } from '../../user-stats.model' import { UserTraits } from '../../user-traits.model' @@ -123,3 +124,7 @@ export async function updateMemberPhoto(handle: string, payload: FormData): Prom export async function downloadProfile(handle: string): Promise { return xhrGetBlobAsync(`${profileUrl(handle)}/profileDownload`) } + +export function getMemberSkillDetails(handle: string, skillId: string): Promise { + return xhrGetAsync(`${profileUrl(handle)}/skills/${skillId}`) +} diff --git a/src/libs/core/lib/profile/user-skill.model.ts b/src/libs/core/lib/profile/user-skill.model.ts index 3670351ad..9e32f1435 100644 --- a/src/libs/core/lib/profile/user-skill.model.ts +++ b/src/libs/core/lib/profile/user-skill.model.ts @@ -38,4 +38,25 @@ export type UserSkill = { description?: string | null | undefined } +export type UserSkillActivity = { + count: number + lastSources: Array<{ + id?: string + completionEventId?: string + title?: string + name?: string + certification?: string + dashedName?: string + }> +} + +export type UserSkillWithActivity = { + lastUsedDate: string + activity: { + certification?: UserSkillActivity + course?: UserSkillActivity + challenge?: UserSkillActivity + } +} & UserSkill + export type SearchUserSkill = Pick & Partial> diff --git a/src/libs/shared/lib/components/grouped-skills-ui/GroupedSkillsUI.tsx b/src/libs/shared/lib/components/grouped-skills-ui/GroupedSkillsUI.tsx index 39b2a07bb..88d55922f 100644 --- a/src/libs/shared/lib/components/grouped-skills-ui/GroupedSkillsUI.tsx +++ b/src/libs/shared/lib/components/grouped-skills-ui/GroupedSkillsUI.tsx @@ -4,11 +4,13 @@ import { UserSkill } from '~/libs/core' import { SkillPill } from '~/libs/shared' import { SkillsList } from '../skills-list' +import { SkillPillProps } from '../skill-pill/SkillPill' import styles from './GroupedSkillsUI.module.scss' interface GroupedSkillsUIProps { groupedSkillsByCategory: { [key: string]: UserSkill[] } + fetchSkillDetails: SkillPillProps['fetchSkillDetails'] } const GroupedSkillsUI: FC = (props: GroupedSkillsUIProps) => (
= (props: GroupedSkillsUIProps) skill={skill} key={skill.id} theme='catList' + fetchSkillDetails={props.fetchSkillDetails} /> )) } diff --git a/src/libs/shared/lib/components/skill-pill/SkillPill.module.scss b/src/libs/shared/lib/components/skill-pill/SkillPill.module.scss index 9740c7b27..f88734fe9 100644 --- a/src/libs/shared/lib/components/skill-pill/SkillPill.module.scss +++ b/src/libs/shared/lib/components/skill-pill/SkillPill.module.scss @@ -69,3 +69,19 @@ align-items: flex-start; } } + +.tooltipRow { + display: flex; + align-items: center; + gap: $sp-1; + text-align: left; + &.padLeft{ + padding-left: $sp-2; + } +} + +.tooltipDetails { + li { + margin-top: $sp-2; + } +} diff --git a/src/libs/shared/lib/components/skill-pill/SkillPill.tsx b/src/libs/shared/lib/components/skill-pill/SkillPill.tsx index ce0031c3b..53307b8a3 100644 --- a/src/libs/shared/lib/components/skill-pill/SkillPill.tsx +++ b/src/libs/shared/lib/components/skill-pill/SkillPill.tsx @@ -1,8 +1,12 @@ -import { FC, ReactNode, useCallback, useMemo } from 'react' +/* eslint-disable complexity */ +import { FC, ReactNode, useCallback, useMemo, useState } from 'react' +import { format } from 'date-fns' import classNames from 'classnames' +import useSWR, { SWRResponse } from 'swr' -import { IconOutline } from '~/libs/ui' -import { UserSkill } from '~/libs/core' +import { EnvironmentConfig } from '~/config' +import { IconOutline, Tooltip } from '~/libs/ui' +import { UserSkill, UserSkillWithActivity } from '~/libs/core' import { isSkillVerified } from '../../services/standard-skills' @@ -12,11 +16,15 @@ export interface SkillPillProps { children?: ReactNode onClick?: (skill: UserSkill) => void selected?: boolean - skill: Partial> + skill: Partial> theme?: 'dark' | 'verified' | 'presentation' | 'etc' | 'catList' + fetchSkillDetails?: (skillId: string) => Promise } const SkillPill: FC = props => { + const [hideDetails, setHideDetails] = useState(false) + const [loadDetails, setLoadDetails] = useState(false) + const isVerified = useMemo(() => ( isSkillVerified({ levels: props.skill.levels ?? [] }) ), [props.skill]) @@ -29,10 +37,103 @@ const SkillPill: FC = props => { styles[`theme-${props.theme ?? ''}`], ) + const handleMouseEnter = useCallback(() => { + setLoadDetails(true) + }, []) + const handleClick = useCallback(() => props.onClick?.call(undefined, props.skill as UserSkill), [ props.onClick, props.skill, ]) + const { data: skillDetails, isValidating: isLoadingDetails }: SWRResponse + = useSWR( + loadDetails + && props.fetchSkillDetails + && props.skill?.id ? `user-skill-activity/${props.skill.id}` : undefined, + () => props.fetchSkillDetails!(props.skill.id!) + .catch(() => { + setHideDetails(true) + return {} as any + }), + ) + + const skillDetailsTooltipContent = useMemo(() => { + if (!skillDetails || isLoadingDetails || hideDetails) { + return 'Loading...' + } + + return ( + <> +
+ Last used: + {format(new Date(skillDetails.lastUsedDate), 'MMM dd, yyyy HH:mm')} +
+
    + {skillDetails.activity.challenge && ( +
  • +
    + Challenges ( + {skillDetails.activity.challenge.count} + ): +
    + {skillDetails.activity.challenge.lastSources.map(s => ( + + {s.name} + + ))} +
  • + )} + {skillDetails.activity.course && ( +
  • +
    + Courses ( + {skillDetails.activity.course.count} + ): +
    + {skillDetails.activity.course.lastSources.map(s => ( + + {s.title} + + ))} +
  • + )} + {skillDetails.activity.certification && ( +
  • +
    + TCA Certifications ( + {skillDetails.activity.certification.count} + ): +
    + {skillDetails.activity.certification.lastSources.map(s => ( + + {s.title} + + ))} +
  • + )} +
+ + ) + }, [skillDetails, isLoadingDetails, hideDetails]) + return (
= props => { {props.skill.name} {props.children} - {isVerified && } + {isVerified && props.fetchSkillDetails && !hideDetails && ( + + + + )} + {isVerified && (!props.fetchSkillDetails || hideDetails) && ( + + )}
) }