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
@@ -0,0 +1,73 @@
import { xhrGetAsync } from '~/libs/core'

import {
fetchExpertSkillCategories,
fetchExpertSkillCategoryMembers,
} from './statistics.service'

jest.mock('~/config', () => ({
EnvironmentConfig: {
API: { V6: 'https://api.example.com/v6' },
REPORTS_API: 'https://reports.example.com',
},
}), {
virtual: true,
})

jest.mock('~/libs/core', () => ({
xhrGetAsync: jest.fn(),
}), {
virtual: true,
})

const mockedXhrGetAsync = xhrGetAsync as jest.MockedFunction<typeof xhrGetAsync>

describe('statistics.service expert-skills', () => {
beforeEach(() => {
mockedXhrGetAsync.mockReset()
})

it('loads skill categories from statistics/expert-skills', async () => {
const categories = [{
color: '#1B4F72',
icon: 'TerminalIcon',
id: '481b5ebc-2fe6-45ed-a90c-736936d458d7',
name: 'Programming and Development',
officialName: 'Programming and Development',
size: 10,
skillsBreakdown: [{ name: 'JavaScript', percentage: 40 }],
totalMembers: 101,
totalSkills: 50,
}]
mockedXhrGetAsync.mockResolvedValueOnce(categories)

await expect(fetchExpertSkillCategories())
.resolves
.toEqual(categories)
expect(mockedXhrGetAsync)
.toHaveBeenCalledWith(
'https://reports.example.com/statistics/expert-skills/categories',
)
})

it('loads category members from statistics/expert-skills', async () => {
const members = [{
countryCode: 'IN',
countryName: 'India',
handle: 'billzedison',
name: 'Honghan W',
rating: 2000,
wins: 376,
}]
mockedXhrGetAsync.mockResolvedValueOnce(members)

await expect(fetchExpertSkillCategoryMembers('Programming and Development'))
.resolves
.toEqual(members)
expect(mockedXhrGetAsync)
.toHaveBeenCalledWith(
'https://reports.example.com/statistics/expert-skills/category-members'
+ '?selectedcategory=Programming+and+Development',
)
})
})
55 changes: 55 additions & 0 deletions src/apps/customer-portal/src/lib/services/statistics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ type CountryLookupResponse = {
}

const GENERAL_STATISTICS_URL = `${EnvironmentConfig.REPORTS_API}/statistics/general`
const EXPERT_SKILLS_STATISTICS_URL = `${EnvironmentConfig.REPORTS_API}/statistics/expert-skills`
const COUNTRY_LOOKUP_URL = `${EnvironmentConfig.API.V6}/lookups/countries?page=1&perPage=9999`

const COUNTRY_NAME_ALIASES: Record<string, string> = {
Expand Down Expand Up @@ -263,3 +264,57 @@ export async function fetchGeneralStatistics(): Promise<GeneralStatistics> {
totalPrizes: Number(totalPrizesResponse.total || 0),
}
}

export type ExpertSkillBreakdown = {
name: string
percentage: number
}

export type ExpertSkillCategory = {
color: string
icon: string
id: string
name: string
officialName: string
size: number
skillsBreakdown: ExpertSkillBreakdown[]
totalMembers: number
totalSkills: number
}

export type ExpertSkillCategoryMember = {
countryCode: string
countryName: string
handle: string
name: string
photoURL?: string | null
rating: number
wins: number
}

export const EXPERT_SKILL_CATEGORIES_CACHE_KEY = 'customer-portal-expert-skill-categories'

export function expertSkillCategoryMembersCacheKey(selectedCategory: string): string {
return `customer-portal-expert-skill-category-members:${selectedCategory}`
}

export async function fetchExpertSkillCategories(): Promise<ExpertSkillCategory[]> {
const response = await xhrGetAsync<ExpertSkillCategory[]>(
`${EXPERT_SKILLS_STATISTICS_URL}/categories`,
)

return Array.isArray(response) ? response : []
}

export async function fetchExpertSkillCategoryMembers(
selectedCategory: string,
): Promise<ExpertSkillCategoryMember[]> {
const query = new URLSearchParams({
selectedcategory: selectedCategory,
})
const response = await xhrGetAsync<ExpertSkillCategoryMember[]>(
`${EXPERT_SKILLS_STATISTICS_URL}/category-members?${query.toString()}`,
)

return Array.isArray(response) ? response : []
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,19 @@
align-items: center;
border: 0;
border-radius: 50%;
box-sizing: border-box;
color: #fff;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
padding: 8px;
padding: 0;
position: absolute;
text-align: center;
transform: translate(-50%, -50%);
transition: box-shadow 160ms ease, transform 160ms ease;
z-index: 1;

svg {
color: #fff;
flex: 0 0 auto;
margin-bottom: 4px;
}

&:hover,
&:focus-visible,
&.hovered {
Expand All @@ -54,12 +48,31 @@
z-index: 4;
}

.bubbleInner {
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
width: 80%;

svg {
color: #fff;
flex: 0 0 auto;
margin-bottom: 4px;
}
}

.label {
display: -webkit-box;
font-family: 'Nunito Sans', sans-serif;
font-weight: 700;
line-height: 1.15;
max-width: 100%;
overflow: hidden;
overflow-wrap: anywhere;
width: 100%;
word-break: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
FC,
KeyboardEvent,
RefObject,
SVGProps,
useCallback,
useEffect,
useLayoutEffect,
Expand All @@ -13,17 +14,20 @@ import {
} from 'react'
import { createPortal } from 'react-dom'
import classNames from 'classnames'
import useSWR, { SWRResponse } from 'swr'

import { getRatingColor } from '~/libs/core'
import { IconOutline } from '~/libs/ui'

import {
ExpertSkillCategory,
ExpertSkillCategoryMember,
expertSkillCategoryMembersCacheKey,
fetchExpertSkillCategoryMembers,
} from '../../../lib'
import memberGroupIcon from '../../statistics/StatisticsPage/assets/member-group.svg'
import skillCognitionIcon from '../../statistics/StatisticsPage/assets/skill-cognition.svg'

import {
getTopMemberForCategory,
SkillCategoryMock,
SkillMemberMock,
} from './mock'
import { packCircles, PackedCircle } from './packCircles'
import styles from './SkillBubblesChart.module.scss'

Expand All @@ -33,8 +37,8 @@ const POPOVER_GAP = 12
const POPOVER_ESTIMATED_HEIGHT = 340
const POPOVER_WIDTH = 320
const VIEW_PAD = 8
const MIN_BUBBLE_FONT_SIZE = 12
const MAX_BUBBLE_FONT_SIZE = 20
const MIN_BUBBLE_FONT_SIZE = 10
const MAX_BUBBLE_FONT_SIZE = 16

type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right'

Expand Down Expand Up @@ -118,12 +122,21 @@ function getPopoverLayout(
}
}

type SkillCategoryIcon = FC<SVGProps<SVGSVGElement>>

interface SkillBubblesChartProps {
categories: SkillCategoryMock[]
categories: ExpertSkillCategory[]
onSelect: (categoryId: string) => void
selectedCategoryId?: string
}

function getCategoryIcon(iconName?: string): SkillCategoryIcon {
const icons = IconOutline as Record<string, SkillCategoryIcon | undefined>
const icon = iconName ? icons[iconName] : undefined

return icon || IconOutline.CodeIcon
}

function radiusForSize(size: number): number {
return 28 + (size * 9)
}
Expand Down Expand Up @@ -207,9 +220,13 @@ const SkillBubblesChart: FC<SkillBubblesChartProps> = props => {
const hoveredCircle = hoveredCategory
? packedById.get(hoveredCategory.id)
: undefined
const topMember = hoveredCategory
? getTopMemberForCategory(hoveredCategory.id)
: undefined
const { data: hoveredMembers }: SWRResponse<ExpertSkillCategoryMember[], Error> = useSWR(
hoveredCategory
? expertSkillCategoryMembersCacheKey(hoveredCategory.name)
: undefined,
() => fetchExpertSkillCategoryMembers(hoveredCategory?.name || ''),
)
const topMember = hoveredMembers?.[0]

const handleKeyDown = useCallback((
event: KeyboardEvent<HTMLButtonElement>,
Expand All @@ -234,14 +251,15 @@ const SkillBubblesChart: FC<SkillBubblesChartProps> = props => {
return undefined
}

const Icon = category.icon
const Icon = getCategoryIcon(category.icon)
const isSelected = category.id === props.selectedCategoryId
const fontSize = fontSizeForRadius(
circle.r,
minPackedRadius,
maxPackedRadius,
)
const iconSize = Math.max(14, Math.min(28, circle.r / 4.6))
const innerSize = circle.r * 1.16
const iconSize = Math.max(12, Math.min(22, innerSize / 5.5))

return (
<button
Expand Down Expand Up @@ -270,8 +288,10 @@ const SkillBubblesChart: FC<SkillBubblesChartProps> = props => {
}}
type='button'
>
<Icon aria-hidden='true' height={iconSize} width={iconSize} />
<span className={styles.label}>{category.name}</span>
<span className={styles.bubbleInner}>
<Icon aria-hidden='true' height={iconSize} width={iconSize} />
<span className={styles.label}>{category.name}</span>
</span>
</button>
)
})}
Expand All @@ -291,12 +311,12 @@ const SkillBubblesChart: FC<SkillBubblesChartProps> = props => {
}

interface SkillCategoryPopoverProps {
category: SkillCategoryMock
category: ExpertSkillCategory
chartHeight: number
chartRef: RefObject<HTMLDivElement>
chartWidth: number
circle: PackedCircle
topMember?: SkillMemberMock
topMember?: ExpertSkillCategoryMember
}

const SkillCategoryPopover = (props: SkillCategoryPopoverProps): JSX.Element => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@ import { getRatingColor } from '~/libs/core'
import { ProfilePicture } from '~/libs/shared'
import { IconOutline } from '~/libs/ui'

import { ExpertSkillCategory, ExpertSkillCategoryMember } from '../../../lib'
import {
IconFirstPlace,
IconSecondPlace,
IconThirdPlace,
} from '../../statistics/StatisticsPage/assets'

import { SkillCategoryMock, SkillMemberMock } from './mock'
import styles from './SkillMembersPanel.module.scss'

const NUMBER_FORMATTER = new Intl.NumberFormat('en-US')

interface SkillMembersPanelProps {
category: SkillCategoryMock
category: ExpertSkillCategory
countryFilter: string
members: SkillMemberMock[]
members: ExpertSkillCategoryMember[]
onCountryChange: (countryCode: string) => void
onSearchChange: (value: string) => void
search: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,17 @@
margin: 20px 0 8px;
text-align: center;
}

.status {
align-items: center;
color: #545f71;
display: flex;
gap: 8px;
justify-content: center;
min-height: 240px;

button {
color: #0d61bf;
text-decoration: underline;
}
}
Loading
Loading