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
22 changes: 18 additions & 4 deletions src/apps/onboarding/src/pages/skills/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
/* eslint-disable react/jsx-no-bind */
/* eslint-disable unicorn/no-null */
import { useNavigate } from 'react-router-dom'
import { FC } from 'react'
import { FC, useState } from 'react'
import classNames from 'classnames'
import { connect } from 'react-redux'

import { Button, PageDivider } from '~/libs/ui'
import { InputSkillSelector } from '~/libs/shared/lib/components/input-skill-selector'
import { Member } from '~/apps/talent-search/src/lib/models'
import { MemberSkillEditor, useMemberSkillEditor } from '~/libs/shared'

import { ProgressBar } from '../../components/progress-bar'

Expand All @@ -18,6 +18,19 @@ export const PageSkillsContent: FC<{
reduxMemberInfo: Member | null
}> = props => {
const navigate: any = useNavigate()
const [loading, setLoading] = useState(false)
const { formInput: emsiFormInput, saveSkills: saveEmsiSkills }: MemberSkillEditor = useMemberSkillEditor()

const saveSkills = async (): Promise<void> => {
setLoading(true)
try {
await saveEmsiSkills()
} catch (error) {
}

setLoading(false)
navigate('../open-to-work')
}

return (
<div className={classNames('d-flex flex-column', styles.container)}>
Expand All @@ -36,7 +49,7 @@ export const PageSkillsContent: FC<{
Understanding your skills will allow us to connect you to the right opportunities.
</span>
<div className='mt-16 full-width color-black-80'>
<InputSkillSelector />
{emsiFormInput}
</div>
</div>
</div>
Expand All @@ -52,7 +65,8 @@ export const PageSkillsContent: FC<{
size='lg'
primary
iconToLeft
onClick={() => navigate('../open-to-work')}
onClick={saveSkills}
disabled={loading}
>
next
</Button>
Expand Down
1 change: 1 addition & 0 deletions src/libs/shared/lib/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './contact-support-form'
export * from './modals'
export * from './member-skill-editor'
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { ChangeEvent, FC } from 'react'
import { noop } from 'lodash'

import { InputMultiselect } from '~/libs/ui'
import { InputMultiselect, InputMultiselectOption } from '~/libs/ui'

import { autoCompleteSkills } from '../../services/emsi-skills'
import { autoCompleteSkills, EmsiSkill, EmsiSkillSources } from '../../services/emsi-skills'

const mapEmsiSkillToInputOption = (s: EmsiSkill): InputMultiselectOption => ({
...s,
label: s.name,
value: s.skillId,
verified: s.skillSources.includes(EmsiSkillSources.challengeWin),
})

interface Option {
label: string
Expand All @@ -21,6 +28,8 @@ const fetchSkills = (queryTerm: string): Promise<Option[]> => (
)

interface InputSkillSelectorProps {
readonly loading?: boolean
readonly value?: EmsiSkill[]
readonly onChange?: (event: ChangeEvent<HTMLInputElement>) => void
}

Expand All @@ -31,6 +40,8 @@ const InputSkillSelector: FC<InputSkillSelectorProps> = props => (
onFetchOptions={fetchSkills}
name='skills'
onChange={props.onChange ?? noop}
value={props.value?.map(mapEmsiSkillToInputOption)}
loading={props.loading}
/>
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './use-member-skill-editor'
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { differenceWith } from 'lodash'

import { profileContext, ProfileContextData } from '~/libs/core'

import {
createMemberEmsiSkills,
EmsiSkill,
EmsiSkillSources,
fetchMemberSkills,
updateMemberEmsiSkills,
} from '../../services/emsi-skills'
import { InputSkillSelector } from '../input-skill-selector'

export interface MemberSkillEditor {
formInput: ReactNode
saveSkills: () => Promise<void>,
}

/**
* Hook to provide functionality for using the member skill editor
* Usage example:
* ```
* const { formInput: emsiFormInput, saveSkills: saveEmsiSkills }: MemberSkillEditor = useMemberSkillEditor()
* ...
* <>
* {emsiFormInput}
* <Button primary onClick={saveEmsiSkills}>Save Skills</Button>
* </>
* ```
* @returns
*/

export const useMemberSkillEditor = (): MemberSkillEditor => {
const { profile }: ProfileContextData = useContext(profileContext)
const [isEmsiInitialized, setIsEmsiInitialized] = useState<boolean>(false)
const [skills, setSkills] = useState<EmsiSkill[]>([])
const [loading, setLoading] = useState<boolean>(true)
const [, setError] = useState<string>()

// Function that saves the updated emsi skills, will be called from outside
const saveSkills = useCallback(async () => {
if (!profile?.userId) {
return
}

const emsiSkills = skills.map(s => ({ emsiId: s.skillId, name: s.name, sources: s.skillSources }))
if (!isEmsiInitialized) {
await createMemberEmsiSkills(profile.userId, emsiSkills)
setIsEmsiInitialized(true)
return
}

updateMemberEmsiSkills(profile.userId, emsiSkills)
}, [isEmsiInitialized, profile?.userId, skills])

// Handle user changes

const handleRemoveSkill = useCallback((skillId: string): void => {
const skill = skills.find(s => s.skillId === skillId)
if (!skill) {
return
}

if (skill.skillSources.includes(EmsiSkillSources.challengeWin)) {
return
}

setSkills(skills.filter(s => s.skillId !== skillId))
}, [skills])

const handleAddSkill = useCallback((skillData: any): void => {
if (skills.find(s => s.skillId === skillData.value)) {
return
}

setSkills([...skills, {
name: skillData.label,
skillId: skillData.value,
skillSources: [EmsiSkillSources.selfPicked],
}])
}, [skills])

const handleOnChange = useCallback(({ target: { value } }: any): void => {
const removed = differenceWith(skills, value, (s, v: any) => s.skillId === v.value)
if (removed.length) {
removed.map(s => handleRemoveSkill(s.skillId))
}

const added = differenceWith(value, skills, (v: any, s: any) => v.value === s.skillId)
if (added.length) {
added.forEach(handleAddSkill)
}
}, [handleAddSkill, handleRemoveSkill, skills])

// Load member's emsi skills, set loading state & isEmsiInitialized
useEffect(() => {
if (!profile?.userId) {
return undefined
}

let mounted = true
fetchMemberSkills(profile.userId)
.catch(e => {
setError(e?.message ?? e)
return []
})
.then(emsiSkills => {
if (!mounted) {
return
}

setIsEmsiInitialized(emsiSkills?.length > 0)
setSkills(emsiSkills)
setLoading(false)
})

return () => { mounted = false }
}, [profile?.userId])

// build the form input
const formInput = useMemo(() => (
<InputSkillSelector value={skills} onChange={handleOnChange} loading={loading} />
), [skills, handleOnChange, loading])

return {
formInput,
saveSkills,
}
}
21 changes: 19 additions & 2 deletions src/libs/shared/lib/services/emsi-skills/emsi-skills.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import { EnvironmentConfig } from '~/config'
import { xhrGetAsync } from '~/libs/core'
import { xhrGetAsync, xhrPostAsync, xhrPutAsync } from '~/libs/core'

import Skill from './skill.model'
import { EmsiSkill, Skill } from './skill.model'

export async function autoCompleteSkills(queryTerm: string): Promise<Skill[]> {
return xhrGetAsync(`${EnvironmentConfig.API.V5}/emsi-skills/skills/auto-complete?term=${queryTerm}`)
}

export async function fetchMemberSkills(userId?: string | number): Promise<EmsiSkill[]> {
return xhrGetAsync(`${EnvironmentConfig.API.V5}/emsi-skills/member-emsi-skills/${userId}`)
}

export async function createMemberEmsiSkills(userId: number, skills: Skill[]): Promise<void> {
return xhrPostAsync(`${EnvironmentConfig.API.V5}/emsi-skills/member-emsi-skills`, {
emsiSkills: skills,
userId,
})
}

export async function updateMemberEmsiSkills(userId: string | number, skills: Skill[]): Promise<void> {
return xhrPutAsync(`${EnvironmentConfig.API.V5}/emsi-skills/member-emsi-skills/${userId}`, {
emsiSkills: skills,
})
}
14 changes: 13 additions & 1 deletion src/libs/shared/lib/services/emsi-skills/skill.model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
export default interface Skill {
export enum EmsiSkillSources {
selfPicked = 'SelfPicked',
challengeWin = 'ChallengeWin',
}

export interface Skill {
name: string;
emsiId: string;
sources?: EmsiSkillSources[];
}

export interface EmsiSkill {
name: string;
skillId: string;
skillSources: EmsiSkillSources[]
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import styles from './InputMultiselect.module.scss'
export interface InputMultiselectOption {
label?: ReactNode
value: string
verified?: boolean
}

interface InputMultiselectProps {
readonly dirty?: boolean
readonly loading?: boolean
readonly disabled?: boolean
readonly error?: string
readonly hideInlineErrors?: boolean
Expand All @@ -29,13 +31,19 @@ interface InputMultiselectProps {
readonly options?: ReadonlyArray<InputMultiselectOption>
readonly placeholder?: string
readonly tabIndex?: number
readonly value?: string
readonly value?: InputMultiselectOption[]
readonly onFetchOptions?: (query: string) => Promise<InputMultiselectOption[]>
}

const MultiValueRemove: FC = (props: any) => (
<components.MultiValueRemove {...props}>
<IconSolid.XCircleIcon />
{props.data.verified ? (
<span title='Topcoder Verified'>
<IconSolid.CheckCircleIcon />
</span>
) : (
<IconSolid.XCircleIcon />
)}
</components.MultiValueRemove>
)

Expand Down Expand Up @@ -70,10 +78,9 @@ const InputMultiselect: FC<InputMultiselectProps> = (props: InputMultiselectProp
onChange={handleOnChange}
onBlur={noop}
blurInputOnSelect={false}
components={{
// MultiValueLabel: () =>
MultiValueRemove,
}}
isLoading={props.loading}
components={{ MultiValueRemove }}
value={props.value}
/>
</InputWrapper>
)
Expand Down