diff --git a/.circleci/config.yml b/.circleci/config.yml index 8cdecd1..1399d0b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -68,7 +68,7 @@ workflows: only: - develop - PM-3893 - - pm-3847_1 + - pm-4068 # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/config/default.js b/config/default.js index b2a10f2..0d786ff 100644 --- a/config/default.js +++ b/config/default.js @@ -158,5 +158,6 @@ module.exports = { API_KEY: process.env.MAILCHIMP_API_KEY, SERVER_PREFIX: process.env.MAILCHIMP_SERVER_PREFIX, LIST_FETCH_COUNT: process.env.MAILCHIMP_LIST_FETCH_COUNT ? Number(process.env.MAILCHIMP_LIST_FETCH_COUNT) : 1000 - } + }, + PDF_SKILLS_PER_CATEGORY: process.env.PDF_SKILLS_PER_CATEGORY ? Number(process.env.PDF_SKILLS_PER_CATEGORY) : 5, } diff --git a/src/common/profileTemplate.js b/src/common/profileTemplate.js index b6e752a..02f34a7 100644 --- a/src/common/profileTemplate.js +++ b/src/common/profileTemplate.js @@ -283,13 +283,26 @@ function createSkillsSubsection (title, verified, notVerified) { ) } +/** + * Create one category line in same style as verified/not verified: bullet label + skills on same line + */ +function createCategorySkillsBlock (categoryName, skillNames) { + if (!skillNames || skillNames.length === 0) return null + return React.createElement( + Text, + { key: `category-${categoryName}`, style: styles.skillsList }, + React.createElement(Text, { style: styles.skillsLabel }, `• ${categoryName}: `), + skillNames.join(', ') + ) +} + /** * Build the PDF template for member profile * @param {Object} pdfData the aggregated PDF data * @returns {Object} React element tree */ function buildProfileTemplate (pdfData) { - const { member, workExperience, education, languages, basicInfo, skills, topcoderActivity, certifications, courses } = pdfData + const { member, workExperience, education, languages, basicInfo, skills, skillsByCategory, topcoderActivity, certifications, courses } = pdfData const children = [] @@ -387,30 +400,32 @@ function buildProfileTemplate (pdfData) { ) } - // Technical Skills Section - const hasSkills = skills.principal.verified.length > 0 || skills.principal.notVerified.length > 0 || - skills.additional.verified.length > 0 || skills.additional.notVerified.length > 0 - if (hasSkills) { - const skillsContent = [ - createSectionHeader('TECHNICAL SKILLS') - ] - - const principalSubsection = createSkillsSubsection( - 'Principal Skills:', - skills.principal.verified, - skills.principal.notVerified - ) - if (principalSubsection) { - skillsContent.push(principalSubsection) + const hasPrincipalSkills = skills && skills.principal && (skills.principal.verified.length > 0 || skills.principal.notVerified.length > 0) + const hasAdditionalByCategory = skillsByCategory && skillsByCategory.length > 0 + if (hasPrincipalSkills || hasAdditionalByCategory) { + const skillsContent = [createSectionHeader('TECHNICAL SKILLS')] + + if (hasPrincipalSkills) { + const principalSubsection = createSkillsSubsection( + 'Principal Skills:', + skills.principal.verified, + skills.principal.notVerified + ) + if (principalSubsection) skillsContent.push(principalSubsection) } - const additionalSubsection = createSkillsSubsection( - 'Additional Skills:', - skills.additional.verified, - skills.additional.notVerified - ) - if (additionalSubsection) { - skillsContent.push(additionalSubsection) + if (hasAdditionalByCategory) { + const additionalItems = skillsByCategory + .map(item => createCategorySkillsBlock(item.categoryName, item.skills)) + .filter(Boolean) + skillsContent.push( + React.createElement( + View, + { key: 'additional-skills-subsection', style: styles.skillsSubsection }, + React.createElement(Text, { style: styles.skillsSubsectionTitle }, 'Additional Skills:'), + ...additionalItems + ) + ) } children.push( diff --git a/src/services/MemberService.js b/src/services/MemberService.js index eece421..6af59fc 100644 --- a/src/services/MemberService.js +++ b/src/services/MemberService.js @@ -489,7 +489,7 @@ async function getProfileCompleteness (currentUser, handle, query) { const memberTraits = await memberTraitService.getTraits(currentUser, handle, {}) // Avoid getting the member stats, since we don't need them here, and performance is // better without them - const memberFields = { 'fields': 'userId,handle,handleLower,photoURL,description,skills,verified,availableForGigs,availableForGigsLastUpdateDate,lastProfileConfirmationDate,updatedAt,addresses' } + const memberFields = { 'fields': 'userId,handle,handleLower,photoURL,description,skills,verified,lastProfileConfirmationDate,updatedAt,addresses' } const member = await getMemberData(handle, memberFields) // Used for calculating the percentComplete @@ -508,7 +508,7 @@ async function getProfileCompleteness (currentUser, handle, query) { // TODO: Turn this back on once we have verification flow implemented elsewhere // data.verified = false data.skills = false - data.gigAvailability = false + data.engagementAvailability = false data.bio = false data.workHistory = false data.education = false @@ -517,19 +517,13 @@ async function getProfileCompleteness (currentUser, handle, query) { const totalItems = Object.keys(data).length data.skillsLastUpdateDate = undefined - data.gigAvailabilityLastUpdateDate = undefined + data.engagementAvailabilityLastUpdateDate = undefined data.workHistoryLastUpdateDate = undefined data.educationLastUpdateDate = undefined data.locationLastUpdateDate = undefined data.profileLastUpdateDate = new Date(member.updatedAt).toISOString() data.lastProfileConfirmationDate = member.lastProfileConfirmationDate ? new Date(member.lastProfileConfirmationDate).toISOString() : undefined - if (member.availableForGigs != null) { - completeItems += 1 - data.gigAvailability = true - data.gigAvailabilityLastUpdateDate = member.availableForGigsLastUpdateDate || undefined - } - _.forEach(memberTraits, (item) => { if (item.traitId === 'education' && item.traits.data.length > 0 && !data.education) { completeItems += 1 @@ -542,6 +536,20 @@ async function getProfileCompleteness (currentUser, handle, query) { data.workHistory = true data.workHistoryLastUpdateDate = new Date(item.updatedAt).toISOString() } + + if (item.traitId === 'personalization' && item.traits.data.length > 0 && !data.engagementAvailability) { + const openToWorkTrait = item.traits.data.find(r => Object.keys(r).includes('openToWork')) || {}; + const openToWorkData = openToWorkTrait.openToWork || {}; + + if (openToWorkData && ( + !openToWorkData.availability || + (openToWorkData.preferredRoles && openToWorkData.preferredRoles.length) + )) { + completeItems += 1 + data.engagementAvailability = true + data.engagementAvailabilityLastUpdateDate = new Date(item.updatedAt).toISOString() + } + } }) // Push on the incomplete traits for picking a random toast to show if (!data.education) { @@ -550,8 +558,8 @@ async function getProfileCompleteness (currentUser, handle, query) { if (!data.workHistory) { showToast.push('workHistory') } - if (!data.gigAvailability) { - showToast.push('gigAvailability') + if (!data.engagementAvailability) { + showToast.push('engagementAvailability') } // TODO: Do we use the short bio or the "description" field of the member object? @@ -1783,30 +1791,33 @@ async function aggregatePDFData (currentUser, handle) { // Fetch skills from standardized-skills-api const skills = await getMemberSkills(memberData.userId) - // Separate skills by display mode and verification status + // Principal skills: same as before (verified / not verified lists) const principalSkills = { verified: [], notVerified: [] } - const additionalSkills = { verified: [], notVerified: [] } - skills.forEach(skill => { - const isPrincipal = _.get(skill, 'displayMode.name') === 'principal' + if (_.get(skill, 'displayMode.name') !== 'principal') return const isVerified = _.some(_.get(skill, 'levels', []), level => level.name === 'verified') const skillName = skill.name - - if (isPrincipal) { - if (isVerified) { - principalSkills.verified.push(skillName) - } else { - principalSkills.notVerified.push(skillName) - } + if (isVerified) { + principalSkills.verified.push(skillName) } else { - if (isVerified) { - additionalSkills.verified.push(skillName) - } else { - additionalSkills.notVerified.push(skillName) - } + principalSkills.notVerified.push(skillName) } }) + // Additional skills: group by category, sort by name, take up to limit per category (env PDF_SKILLS_PER_CATEGORY, default 5) + const additionalSkills = skills.filter(skill => _.get(skill, 'displayMode.name') !== 'principal') + const skillsPerCategoryLimit = Math.max(1, parseInt(config.PDF_SKILLS_PER_CATEGORY, 10) || 5) + const categoryKey = (skill) => (skill.category && skill.category.name) ? skill.category.name : 'Other' + const byCategory = _.groupBy(additionalSkills, categoryKey) + const skillsByCategory = _.map(byCategory, (skillList, categoryName) => { + const names = _.map(skillList, 'name') + .filter(Boolean) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) + .slice(0, skillsPerCategoryLimit) + return { categoryName, skills: names } + }).filter(item => item.skills.length > 0) + skillsByCategory.sort((a, b) => a.categoryName.localeCompare(b.categoryName, undefined, { sensitivity: 'base' })) + const specialRoles = [] const roleMap = { 'copilot': 'Copilot', @@ -1913,10 +1924,8 @@ async function aggregatePDFData (currentUser, handle) { shortBio: shortBio }, // Skills - skills: { - principal: principalSkills, - additional: additionalSkills - }, + skills: { principal: principalSkills }, + skillsByCategory, // Topcoder activity topcoderActivity: { specialRole: specialRoles.length > 0 ? `Topcoder Special Role: ${specialRoles.join(', ')}` : null,