Skip to content

Commit 9acfb5d

Browse files
authored
Merge pull request #727 from topcoder-platform/issue-725
Test new skill selector integration in onboarding
2 parents 828ad1f + 24238be commit 9acfb5d

File tree

8 files changed

+208
-15
lines changed

8 files changed

+208
-15
lines changed

src/apps/onboarding/src/pages/skills/index.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
/* eslint-disable react/jsx-no-bind */
33
/* eslint-disable unicorn/no-null */
44
import { useNavigate } from 'react-router-dom'
5-
import { FC } from 'react'
5+
import { FC, useState } from 'react'
66
import classNames from 'classnames'
77
import { connect } from 'react-redux'
88

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

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

@@ -18,6 +18,19 @@ export const PageSkillsContent: FC<{
1818
reduxMemberInfo: Member | null
1919
}> = props => {
2020
const navigate: any = useNavigate()
21+
const [loading, setLoading] = useState(false)
22+
const { formInput: emsiFormInput, saveSkills: saveEmsiSkills }: MemberSkillEditor = useMemberSkillEditor()
23+
24+
const saveSkills = async (): Promise<void> => {
25+
setLoading(true)
26+
try {
27+
await saveEmsiSkills()
28+
} catch (error) {
29+
}
30+
31+
setLoading(false)
32+
navigate('../open-to-work')
33+
}
2134

2235
return (
2336
<div className={classNames('d-flex flex-column', styles.container)}>
@@ -36,7 +49,7 @@ export const PageSkillsContent: FC<{
3649
Understanding your skills will allow us to connect you to the right opportunities.
3750
</span>
3851
<div className='mt-16 full-width color-black-80'>
39-
<InputSkillSelector />
52+
{emsiFormInput}
4053
</div>
4154
</div>
4255
</div>
@@ -52,7 +65,8 @@ export const PageSkillsContent: FC<{
5265
size='lg'
5366
primary
5467
iconToLeft
55-
onClick={() => navigate('../open-to-work')}
68+
onClick={saveSkills}
69+
disabled={loading}
5670
>
5771
next
5872
</Button>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './contact-support-form'
22
export * from './modals'
3+
export * from './member-skill-editor'

src/libs/shared/lib/components/input-skill-selector/InputSkillSelector.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import { ChangeEvent, FC } from 'react'
22
import { noop } from 'lodash'
33

4-
import { InputMultiselect } from '~/libs/ui'
4+
import { InputMultiselect, InputMultiselectOption } from '~/libs/ui'
55

6-
import { autoCompleteSkills } from '../../services/emsi-skills'
6+
import { autoCompleteSkills, EmsiSkill, EmsiSkillSources } from '../../services/emsi-skills'
7+
8+
const mapEmsiSkillToInputOption = (s: EmsiSkill): InputMultiselectOption => ({
9+
...s,
10+
label: s.name,
11+
value: s.skillId,
12+
verified: s.skillSources.includes(EmsiSkillSources.challengeWin),
13+
})
714

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

2330
interface InputSkillSelectorProps {
31+
readonly loading?: boolean
32+
readonly value?: EmsiSkill[]
2433
readonly onChange?: (event: ChangeEvent<HTMLInputElement>) => void
2534
}
2635

@@ -31,6 +40,8 @@ const InputSkillSelector: FC<InputSkillSelectorProps> = props => (
3140
onFetchOptions={fetchSkills}
3241
name='skills'
3342
onChange={props.onChange ?? noop}
43+
value={props.value?.map(mapEmsiSkillToInputOption)}
44+
loading={props.loading}
3445
/>
3546
)
3647

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './use-member-skill-editor'
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
2+
import { differenceWith } from 'lodash'
3+
4+
import { profileContext, ProfileContextData } from '~/libs/core'
5+
6+
import {
7+
createMemberEmsiSkills,
8+
EmsiSkill,
9+
EmsiSkillSources,
10+
fetchMemberSkills,
11+
updateMemberEmsiSkills,
12+
} from '../../services/emsi-skills'
13+
import { InputSkillSelector } from '../input-skill-selector'
14+
15+
export interface MemberSkillEditor {
16+
formInput: ReactNode
17+
saveSkills: () => Promise<void>,
18+
}
19+
20+
/**
21+
* Hook to provide functionality for using the member skill editor
22+
* Usage example:
23+
* ```
24+
* const { formInput: emsiFormInput, saveSkills: saveEmsiSkills }: MemberSkillEditor = useMemberSkillEditor()
25+
* ...
26+
* <>
27+
* {emsiFormInput}
28+
* <Button primary onClick={saveEmsiSkills}>Save Skills</Button>
29+
* </>
30+
* ```
31+
* @returns
32+
*/
33+
34+
export const useMemberSkillEditor = (): MemberSkillEditor => {
35+
const { profile }: ProfileContextData = useContext(profileContext)
36+
const [isEmsiInitialized, setIsEmsiInitialized] = useState<boolean>(false)
37+
const [skills, setSkills] = useState<EmsiSkill[]>([])
38+
const [loading, setLoading] = useState<boolean>(true)
39+
const [, setError] = useState<string>()
40+
41+
// Function that saves the updated emsi skills, will be called from outside
42+
const saveSkills = useCallback(async () => {
43+
if (!profile?.userId) {
44+
return
45+
}
46+
47+
const emsiSkills = skills.map(s => ({ emsiId: s.skillId, name: s.name, sources: s.skillSources }))
48+
if (!isEmsiInitialized) {
49+
await createMemberEmsiSkills(profile.userId, emsiSkills)
50+
setIsEmsiInitialized(true)
51+
return
52+
}
53+
54+
updateMemberEmsiSkills(profile.userId, emsiSkills)
55+
}, [isEmsiInitialized, profile?.userId, skills])
56+
57+
// Handle user changes
58+
59+
const handleRemoveSkill = useCallback((skillId: string): void => {
60+
const skill = skills.find(s => s.skillId === skillId)
61+
if (!skill) {
62+
return
63+
}
64+
65+
if (skill.skillSources.includes(EmsiSkillSources.challengeWin)) {
66+
return
67+
}
68+
69+
setSkills(skills.filter(s => s.skillId !== skillId))
70+
}, [skills])
71+
72+
const handleAddSkill = useCallback((skillData: any): void => {
73+
if (skills.find(s => s.skillId === skillData.value)) {
74+
return
75+
}
76+
77+
setSkills([...skills, {
78+
name: skillData.label,
79+
skillId: skillData.value,
80+
skillSources: [EmsiSkillSources.selfPicked],
81+
}])
82+
}, [skills])
83+
84+
const handleOnChange = useCallback(({ target: { value } }: any): void => {
85+
const removed = differenceWith(skills, value, (s, v: any) => s.skillId === v.value)
86+
if (removed.length) {
87+
removed.map(s => handleRemoveSkill(s.skillId))
88+
}
89+
90+
const added = differenceWith(value, skills, (v: any, s: any) => v.value === s.skillId)
91+
if (added.length) {
92+
added.forEach(handleAddSkill)
93+
}
94+
}, [handleAddSkill, handleRemoveSkill, skills])
95+
96+
// Load member's emsi skills, set loading state & isEmsiInitialized
97+
useEffect(() => {
98+
if (!profile?.userId) {
99+
return undefined
100+
}
101+
102+
let mounted = true
103+
fetchMemberSkills(profile.userId)
104+
.catch(e => {
105+
setError(e?.message ?? e)
106+
return []
107+
})
108+
.then(emsiSkills => {
109+
if (!mounted) {
110+
return
111+
}
112+
113+
setIsEmsiInitialized(emsiSkills?.length > 0)
114+
setSkills(emsiSkills)
115+
setLoading(false)
116+
})
117+
118+
return () => { mounted = false }
119+
}, [profile?.userId])
120+
121+
// build the form input
122+
const formInput = useMemo(() => (
123+
<InputSkillSelector value={skills} onChange={handleOnChange} loading={loading} />
124+
), [skills, handleOnChange, loading])
125+
126+
return {
127+
formInput,
128+
saveSkills,
129+
}
130+
}
Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
11
import { EnvironmentConfig } from '~/config'
2-
import { xhrGetAsync } from '~/libs/core'
2+
import { xhrGetAsync, xhrPostAsync, xhrPutAsync } from '~/libs/core'
33

4-
import Skill from './skill.model'
4+
import { EmsiSkill, Skill } from './skill.model'
55

66
export async function autoCompleteSkills(queryTerm: string): Promise<Skill[]> {
77
return xhrGetAsync(`${EnvironmentConfig.API.V5}/emsi-skills/skills/auto-complete?term=${queryTerm}`)
88
}
9+
10+
export async function fetchMemberSkills(userId?: string | number): Promise<EmsiSkill[]> {
11+
return xhrGetAsync(`${EnvironmentConfig.API.V5}/emsi-skills/member-emsi-skills/${userId}`)
12+
}
13+
14+
export async function createMemberEmsiSkills(userId: number, skills: Skill[]): Promise<void> {
15+
return xhrPostAsync(`${EnvironmentConfig.API.V5}/emsi-skills/member-emsi-skills`, {
16+
emsiSkills: skills,
17+
userId,
18+
})
19+
}
20+
21+
export async function updateMemberEmsiSkills(userId: string | number, skills: Skill[]): Promise<void> {
22+
return xhrPutAsync(`${EnvironmentConfig.API.V5}/emsi-skills/member-emsi-skills/${userId}`, {
23+
emsiSkills: skills,
24+
})
25+
}
Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
1-
export default interface Skill {
1+
export enum EmsiSkillSources {
2+
selfPicked = 'SelfPicked',
3+
challengeWin = 'ChallengeWin',
4+
}
5+
6+
export interface Skill {
27
name: string;
38
emsiId: string;
9+
sources?: EmsiSkillSources[];
10+
}
11+
12+
export interface EmsiSkill {
13+
name: string;
14+
skillId: string;
15+
skillSources: EmsiSkillSources[]
416
}

src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ import styles from './InputMultiselect.module.scss'
1515
export interface InputMultiselectOption {
1616
label?: ReactNode
1717
value: string
18+
verified?: boolean
1819
}
1920

2021
interface InputMultiselectProps {
2122
readonly dirty?: boolean
23+
readonly loading?: boolean
2224
readonly disabled?: boolean
2325
readonly error?: string
2426
readonly hideInlineErrors?: boolean
@@ -29,13 +31,19 @@ interface InputMultiselectProps {
2931
readonly options?: ReadonlyArray<InputMultiselectOption>
3032
readonly placeholder?: string
3133
readonly tabIndex?: number
32-
readonly value?: string
34+
readonly value?: InputMultiselectOption[]
3335
readonly onFetchOptions?: (query: string) => Promise<InputMultiselectOption[]>
3436
}
3537

3638
const MultiValueRemove: FC = (props: any) => (
3739
<components.MultiValueRemove {...props}>
38-
<IconSolid.XCircleIcon />
40+
{props.data.verified ? (
41+
<span title='Topcoder Verified'>
42+
<IconSolid.CheckCircleIcon />
43+
</span>
44+
) : (
45+
<IconSolid.XCircleIcon />
46+
)}
3947
</components.MultiValueRemove>
4048
)
4149

@@ -70,10 +78,9 @@ const InputMultiselect: FC<InputMultiselectProps> = (props: InputMultiselectProp
7078
onChange={handleOnChange}
7179
onBlur={noop}
7280
blurInputOnSelect={false}
73-
components={{
74-
// MultiValueLabel: () =>
75-
MultiValueRemove,
76-
}}
81+
isLoading={props.loading}
82+
components={{ MultiValueRemove }}
83+
value={props.value}
7784
/>
7885
</InputWrapper>
7986
)

0 commit comments

Comments
 (0)