Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -23,6 +24,7 @@ interface MemberSkillsInfoProps {
const MemberSkillsInfo: FC<MemberSkillsInfoProps> = (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)
Expand Down Expand Up @@ -131,6 +133,15 @@ const MemberSkillsInfo: FC<MemberSkillsInfoProps> = (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 (
<div className={styles.container}>
<div className={styles.titleWrap}>
Expand Down Expand Up @@ -174,6 +185,7 @@ const MemberSkillsInfo: FC<MemberSkillsInfoProps> = (props: MemberSkillsInfoProp
skill={skill}
key={skill.id}
theme={isSkillVerified(skill) ? 'verified' : 'dark'}
fetchSkillDetails={canFetchSkillDetails ? fetchSkillDetails : undefined}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Passing undefined to fetchSkillDetails when canFetchSkillDetails is false might lead to unexpected behavior if SkillPill or GroupedSkillsUI do not handle undefined properly. Consider ensuring these components can handle undefined gracefully.

/>
))}
</div>
Expand All @@ -188,6 +200,7 @@ const MemberSkillsInfo: FC<MemberSkillsInfoProps> = (props: MemberSkillsInfoProp
)}
<GroupedSkillsUI
groupedSkillsByCategory={groupedSkillsByCategory}
fetchSkillDetails={canFetchSkillDetails ? fetchSkillDetails : undefined}
/>
</div>
)}
Expand Down
2 changes: 2 additions & 0 deletions src/config/environments/default.env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
2 changes: 2 additions & 0 deletions src/config/environments/global-config.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export {
get as profileStoreGet,
patchName as profileStorePatchName,
getMemberStats,
getMemberSkillDetails,
} from './profile-xhr.store'

export {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -123,3 +124,7 @@ export async function updateMemberPhoto(handle: string, payload: FormData): Prom
export async function downloadProfile(handle: string): Promise<Blob> {
return xhrGetBlobAsync<Blob>(`${profileUrl(handle)}/profileDownload`)
}

export function getMemberSkillDetails(handle: string, skillId: string): Promise<UserSkillWithActivity> {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ maintainability]
Consider adding error handling for the xhrGetAsync call to manage potential network or API errors gracefully. This will improve the robustness of the function.

return xhrGetAsync<UserSkillWithActivity>(`${profileUrl(handle)}/skills/${skillId}`)
}
21 changes: 21 additions & 0 deletions src/libs/core/lib/profile/user-skill.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[⚠️ correctness]
Consider using a more descriptive type for lastUsedDate such as Date instead of string to ensure date operations are type-safe and consistent.

activity: {
certification?: UserSkillActivity
course?: UserSkillActivity
challenge?: UserSkillActivity
}
} & UserSkill

export type SearchUserSkill = Pick<UserSkill, 'id'|'name'> & Partial<Pick<UserSkill, 'levels'>>
Original file line number Diff line number Diff line change
Expand Up @@ -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<GroupedSkillsUIProps> = (props: GroupedSkillsUIProps) => (
<div
Expand All @@ -29,6 +31,7 @@ const GroupedSkillsUI: FC<GroupedSkillsUIProps> = (props: GroupedSkillsUIProps)
skill={skill}
key={skill.id}
theme='catList'
fetchSkillDetails={props.fetchSkillDetails}
/>
))
}
Expand Down
16 changes: 16 additions & 0 deletions src/libs/shared/lib/components/skill-pill/SkillPill.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
121 changes: 116 additions & 5 deletions src/libs/shared/lib/components/skill-pill/SkillPill.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { FC, ReactNode, useCallback, useMemo } from 'react'
/* eslint-disable complexity */
import { FC, ReactNode, useCallback, useMemo, useState } from 'react'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[💡 maintainability]
Consider removing the complexity ESLint rule disable directive if it's not necessary. Disabling rules can hide potential issues.

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'

Expand All @@ -12,11 +16,15 @@ export interface SkillPillProps {
children?: ReactNode
onClick?: (skill: UserSkill) => void
selected?: boolean
skill: Partial<Pick<UserSkill, 'name'|'levels'>>
skill: Partial<Pick<UserSkill, 'id'|'name'|'levels'>>
theme?: 'dark' | 'verified' | 'presentation' | 'etc' | 'catList'
fetchSkillDetails?: (skillId: string) => Promise<UserSkillWithActivity>
}

const SkillPill: FC<SkillPillProps> = props => {
const [hideDetails, setHideDetails] = useState(false)
const [loadDetails, setLoadDetails] = useState(false)

const isVerified = useMemo(() => (
isSkillVerified({ levels: props.skill.levels ?? [] })
), [props.skill])
Expand All @@ -29,10 +37,103 @@ const SkillPill: FC<SkillPillProps> = 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<UserSkillWithActivity>
= useSWR<UserSkillWithActivity>(
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 (
<>
<div className={styles.tooltipRow}>
<strong>Last used:</strong>
<span>{format(new Date(skillDetails.lastUsedDate), 'MMM dd, yyyy HH:mm')}</span>
</div>
<ul className={styles.tooltipDetails}>
{skillDetails.activity.challenge && (
<li>
<div className={styles.tooltipRow}>
Challenges (
{skillDetails.activity.challenge.count}
):
</div>
{skillDetails.activity.challenge.lastSources.map(s => (
<a
key={s.id}
className={classNames(styles.tooltipRow, styles.padLeft)}
href={`${EnvironmentConfig.URLS.CHALLENGES_PAGE}/${s.id}`}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ security]
The target='blank' attribute should be target='_blank' to correctly open links in a new tab. Additionally, consider adding rel='noopener noreferrer' for security reasons to prevent the new page from gaining access to the window.opener property.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For fix.

target='_blank'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ security]
The target='_blank' attribute should always be accompanied by rel='noopener noreferrer' to prevent security vulnerabilities such as reverse tabnabbing. This has been correctly added here.

rel='noopener noreferrer'
>
{s.name}
</a>
))}
</li>
)}
{skillDetails.activity.course && (
<li>
<div className={styles.tooltipRow}>
Courses (
{skillDetails.activity.course.count}
):
</div>
{skillDetails.activity.course.lastSources.map(s => (
<a
key={s.completionEventId}
className={classNames(styles.tooltipRow, styles.padLeft)}
href={`${EnvironmentConfig.URLS.ACADEMY_COURSE}/${s.certification}`}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ security]
The target='blank' attribute should be target='_blank' to correctly open links in a new tab. Additionally, consider adding rel='noopener noreferrer' for security reasons to prevent the new page from gaining access to the window.opener property.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vas3a let's fix.

target='_blank'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ security]
The target='_blank' attribute should always be accompanied by rel='noopener noreferrer' to prevent security vulnerabilities such as reverse tabnabbing. This has been correctly added here.

rel='noopener noreferrer'
>
{s.title}
</a>
))}
</li>
)}
{skillDetails.activity.certification && (
<li>
<div className={styles.tooltipRow}>
TCA Certifications (
{skillDetails.activity.certification.count}
):
</div>
{skillDetails.activity.certification.lastSources.map(s => (
<a
key={s.completionEventId}
className={classNames(styles.tooltipRow, styles.padLeft)}
href={`${EnvironmentConfig.URLS.ACADEMY_CERTIFICATION}/${s.dashedName}`}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ security]
The target='blank' attribute should be target='_blank' to correctly open links in a new tab. Additionally, consider adding rel='noopener noreferrer' for security reasons to prevent the new page from gaining access to the window.opener property.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To fix.

target='_blank'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[❗❗ security]
The target='_blank' attribute should always be accompanied by rel='noopener noreferrer' to prevent security vulnerabilities such as reverse tabnabbing. This has been correctly added here.

rel='noopener noreferrer'
>
{s.title}
</a>
))}
</li>
)}
</ul>
</>
)
}, [skillDetails, isLoadingDetails, hideDetails])

return (
<div
className={className}
Expand All @@ -42,7 +143,17 @@ const SkillPill: FC<SkillPillProps> = props => {
{props.skill.name}
{props.children}
</span>
{isVerified && <IconOutline.CheckCircleIcon />}
{isVerified && props.fetchSkillDetails && !hideDetails && (
<Tooltip
clickable
content={isLoadingDetails ? 'Loading...' : skillDetailsTooltipContent}
>
<IconOutline.CheckCircleIcon onMouseEnter={handleMouseEnter} />
</Tooltip>
)}
{isVerified && (!props.fetchSkillDetails || hideDetails) && (
<IconOutline.CheckCircleIcon />
)}
</div>
)
}
Expand Down
Loading