From 3313bb2cfddcd1cd35896f338892b7ff466a33ac Mon Sep 17 00:00:00 2001 From: dat Date: Tue, 11 Jul 2023 00:15:02 +0700 Subject: [PATCH 1/2] Merge branch 'EMSI-skill-editor' into onboarding --- src/libs/shared/lib/components/index.ts | 1 + .../InputSkillSelector.tsx | 15 +- .../components/member-skill-editor/index.ts | 1 + .../use-member-skill-editor.tsx | 130 ++++++++++++++++++ .../emsi-skills/emsi-skills.service.ts | 21 ++- .../lib/services/emsi-skills/skill.model.ts | 14 +- .../input-multiselect/InputMultiselect.tsx | 19 ++- 7 files changed, 190 insertions(+), 11 deletions(-) create mode 100644 src/libs/shared/lib/components/member-skill-editor/index.ts create mode 100644 src/libs/shared/lib/components/member-skill-editor/use-member-skill-editor.tsx diff --git a/src/libs/shared/lib/components/index.ts b/src/libs/shared/lib/components/index.ts index 732eb1045..e3a4f8034 100644 --- a/src/libs/shared/lib/components/index.ts +++ b/src/libs/shared/lib/components/index.ts @@ -1,2 +1,3 @@ export * from './contact-support-form' export * from './modals' +export * from './member-skill-editor' diff --git a/src/libs/shared/lib/components/input-skill-selector/InputSkillSelector.tsx b/src/libs/shared/lib/components/input-skill-selector/InputSkillSelector.tsx index 78f0800ed..9b206b30e 100644 --- a/src/libs/shared/lib/components/input-skill-selector/InputSkillSelector.tsx +++ b/src/libs/shared/lib/components/input-skill-selector/InputSkillSelector.tsx @@ -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 @@ -21,6 +28,8 @@ const fetchSkills = (queryTerm: string): Promise => ( ) interface InputSkillSelectorProps { + readonly loading?: boolean + readonly value?: EmsiSkill[] readonly onChange?: (event: ChangeEvent) => void } @@ -31,6 +40,8 @@ const InputSkillSelector: FC = props => ( onFetchOptions={fetchSkills} name='skills' onChange={props.onChange ?? noop} + value={props.value?.map(mapEmsiSkillToInputOption)} + loading={props.loading} /> ) diff --git a/src/libs/shared/lib/components/member-skill-editor/index.ts b/src/libs/shared/lib/components/member-skill-editor/index.ts new file mode 100644 index 000000000..3e9f3be1a --- /dev/null +++ b/src/libs/shared/lib/components/member-skill-editor/index.ts @@ -0,0 +1 @@ +export * from './use-member-skill-editor' diff --git a/src/libs/shared/lib/components/member-skill-editor/use-member-skill-editor.tsx b/src/libs/shared/lib/components/member-skill-editor/use-member-skill-editor.tsx new file mode 100644 index 000000000..5e9cf2ee6 --- /dev/null +++ b/src/libs/shared/lib/components/member-skill-editor/use-member-skill-editor.tsx @@ -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, +} + +/** + * Hook to provide functionality for using the member skill editor + * Usage example: + * ``` + * const { formInput: emsiFormInput, saveSkills: saveEmsiSkills }: MemberSkillEditor = useMemberSkillEditor() + * ... + * <> + * {emsiFormInput} + * + * + * ``` + * @returns + */ + +export const useMemberSkillEditor = (): MemberSkillEditor => { + const { profile }: ProfileContextData = useContext(profileContext) + const [isEmsiInitialized, setIsEmsiInitialized] = useState(false) + const [skills, setSkills] = useState([]) + const [loading, setLoading] = useState(true) + const [, setError] = useState() + + // 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(() => ( + + ), [skills, handleOnChange, loading]) + + return { + formInput, + saveSkills, + } +} diff --git a/src/libs/shared/lib/services/emsi-skills/emsi-skills.service.ts b/src/libs/shared/lib/services/emsi-skills/emsi-skills.service.ts index 0adc93c5d..835fcd9a3 100644 --- a/src/libs/shared/lib/services/emsi-skills/emsi-skills.service.ts +++ b/src/libs/shared/lib/services/emsi-skills/emsi-skills.service.ts @@ -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 { return xhrGetAsync(`${EnvironmentConfig.API.V5}/emsi-skills/skills/auto-complete?term=${queryTerm}`) } + +export async function fetchMemberSkills(userId?: string | number): Promise { + return xhrGetAsync(`${EnvironmentConfig.API.V5}/emsi-skills/member-emsi-skills/${userId}`) +} + +export async function createMemberEmsiSkills(userId: number, skills: Skill[]): Promise { + return xhrPostAsync(`${EnvironmentConfig.API.V5}/emsi-skills/member-emsi-skills`, { + emsiSkills: skills, + userId, + }) +} + +export async function updateMemberEmsiSkills(userId: string | number, skills: Skill[]): Promise { + return xhrPutAsync(`${EnvironmentConfig.API.V5}/emsi-skills/member-emsi-skills/${userId}`, { + emsiSkills: skills, + }) +} diff --git a/src/libs/shared/lib/services/emsi-skills/skill.model.ts b/src/libs/shared/lib/services/emsi-skills/skill.model.ts index e9c21d4fc..abde46c47 100644 --- a/src/libs/shared/lib/services/emsi-skills/skill.model.ts +++ b/src/libs/shared/lib/services/emsi-skills/skill.model.ts @@ -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[] } diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.tsx b/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.tsx index 33bd0405b..c28e60dda 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.tsx +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-multiselect/InputMultiselect.tsx @@ -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 @@ -29,13 +31,19 @@ interface InputMultiselectProps { readonly options?: ReadonlyArray readonly placeholder?: string readonly tabIndex?: number - readonly value?: string + readonly value?: InputMultiselectOption[] readonly onFetchOptions?: (query: string) => Promise } const MultiValueRemove: FC = (props: any) => ( - + {props.data.verified ? ( + + + + ) : ( + + )} ) @@ -70,10 +78,9 @@ const InputMultiselect: FC = (props: InputMultiselectProp onChange={handleOnChange} onBlur={noop} blurInputOnSelect={false} - components={{ - // MultiValueLabel: () => - MultiValueRemove, - }} + isLoading={props.loading} + components={{ MultiValueRemove }} + value={props.value} /> ) From 24238beeacfe262f6cd2d31a9d35cf72dd236098 Mon Sep 17 00:00:00 2001 From: dat Date: Tue, 11 Jul 2023 00:40:33 +0700 Subject: [PATCH 2/2] Test new skill selector integration in onboarding --- .../onboarding/src/pages/skills/index.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/apps/onboarding/src/pages/skills/index.tsx b/src/apps/onboarding/src/pages/skills/index.tsx index 428298105..cfda82bc2 100644 --- a/src/apps/onboarding/src/pages/skills/index.tsx +++ b/src/apps/onboarding/src/pages/skills/index.tsx @@ -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' @@ -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 => { + setLoading(true) + try { + await saveEmsiSkills() + } catch (error) { + } + + setLoading(false) + navigate('../open-to-work') + } return (
@@ -36,7 +49,7 @@ export const PageSkillsContent: FC<{ Understanding your skills will allow us to connect you to the right opportunities.
- + {emsiFormInput}
@@ -52,7 +65,8 @@ export const PageSkillsContent: FC<{ size='lg' primary iconToLeft - onClick={() => navigate('../open-to-work')} + onClick={saveSkills} + disabled={loading} > next