feat(client): add job experience widget to profile (#63503)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
Co-authored-by: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com>
This commit is contained in:
Sem Bauke
2026-01-22 09:30:50 +01:00
committed by GitHub
parent 59a54b5040
commit f68b471d6d
36 changed files with 1274 additions and 29 deletions
+22 -10
View File
@@ -55,17 +55,28 @@ type Portfolio {
url String
}
type Experience {
id String
title String
company String
location String?
startDate String
endDate String?
description String
}
type ProfileUI {
isLocked Boolean? // Undefined
showAbout Boolean? // Undefined
showCerts Boolean? // Undefined
showDonation Boolean? // Undefined
showHeatMap Boolean? // Undefined
showLocation Boolean? // Undefined
showName Boolean? // Undefined
showPoints Boolean? // Undefined
showPortfolio Boolean? // Undefined
showTimeLine Boolean? // Undefined
isLocked Boolean? // Undefined
showAbout Boolean? // Undefined
showCerts Boolean? // Undefined
showDonation Boolean? // Undefined
showHeatMap Boolean? // Undefined
showLocation Boolean? // Undefined
showName Boolean? // Undefined
showPoints Boolean? // Undefined
showPortfolio Boolean? // Undefined
showExperience Boolean? // Undefined
showTimeLine Boolean? // Undefined
}
type SavedChallengeFile {
@@ -152,6 +163,7 @@ model user {
password String? // Undefined
picture String?
portfolio Portfolio[]
experience Experience[]
profileUI ProfileUI? // Undefined
progressTimestamps Json? // ProgressTimestamp[] | Null[] | Int64[] | Double[] - TODO: NORMALIZE
/// A random number between 0 and 1.
+1 -1
View File
@@ -64,7 +64,7 @@ type FastifyInstanceWithTypeProvider = FastifyInstance<
const ajv = new Ajv({
coerceTypes: 'array', // change data type of data to match type keyword
useDefaults: true, // replace missing properties and items with the values from corresponding default keyword
removeAdditional: true, // remove additional properties
removeAdditional: 'all', // remove additional properties
uriResolver,
addUsedSchema: false,
// Explicitly set allErrors to `false`.
+2
View File
@@ -21,6 +21,7 @@ export const newUser = (email: string) => ({
emailAuthLinkTTL: null,
emailVerified: true,
emailVerifyTTL: null,
experience: [],
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
externalId: expect.stringMatching(uuidRe),
githubProfile: null,
@@ -80,6 +81,7 @@ export const newUser = (email: string) => ({
showAbout: false,
showCerts: false,
showDonation: false,
showExperience: false,
showHeatMap: false,
showLocation: false,
showName: false,
+231 -1
View File
@@ -32,6 +32,7 @@ const baseProfileUI = {
showAbout: false,
showCerts: false,
showDonation: false,
showExperience: false,
showHeatMap: false,
showLocation: false,
showName: false,
@@ -1192,6 +1193,234 @@ Happy coding!
});
});
describe('/update-my-experience', () => {
test('PUT returns 200 status code with "success" message and saves experience', async () => {
const payload = {
experience: [
{
id: '1',
title: 'Software Engineer',
company: 'Tech Corp',
location: 'Remote',
startDate: '2020-01',
endDate: '2022-06',
description: 'Worked on various projects'
}
]
};
const response = await superPut('/update-my-experience').send(payload);
expect(response.body).toEqual({
message: 'flash.experience-updated',
type: 'success'
});
expect(response.statusCode).toEqual(200);
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: developerUserEmail },
select: { experience: true }
});
expect(user?.experience).toEqual(payload.experience);
});
test('rejects extraneous keys on entries', async () => {
const res = await superPut('/update-my-experience').send({
experience: [
{
id: 'x',
title: 'Dev',
company: 'Co',
startDate: '',
description: '',
foo: 'bar'
}
]
});
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: developerUserEmail },
select: { experience: true }
});
expect(user?.experience).toEqual([
{
id: 'x',
title: 'Dev',
company: 'Co',
location: null,
startDate: '',
endDate: null,
description: ''
}
]);
expect(res.statusCode).toBe(200);
});
test('returns 400 when experience is not an array', async () => {
const response = await superPut('/update-my-experience').send({
experience: { not: 'an array' } as unknown as []
});
expect(response.body).toEqual(updateErrorResponse);
expect(response.statusCode).toEqual(400);
});
test('supports current position (omitted endDate becomes null)', async () => {
const response = await superPut('/update-my-experience').send({
experience: [
{
id: 'cur',
title: 'Engineer',
company: 'Now Co',
startDate: '2023-01',
description: ''
// endDate omitted
}
]
});
expect(response.statusCode).toEqual(200);
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: developerUserEmail },
select: { experience: true }
});
expect(user?.experience?.[0]).toEqual({
id: 'cur',
title: 'Engineer',
company: 'Now Co',
location: null,
startDate: '2023-01',
endDate: null,
description: ''
});
});
test('accepts long descriptions', async () => {
const long = 'x'.repeat(1000);
const response = await superPut('/update-my-experience').send({
experience: [
{
id: '',
title: 'Writer',
company: 'Docs Inc',
startDate: '2020-01',
endDate: '2020-12',
description: long
}
]
});
expect(response.statusCode).toEqual(200);
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: developerUserEmail },
select: { experience: true }
});
expect(user?.experience?.[0]?.description).toEqual(long);
});
test('PUT accepts empty array and clears experience', async () => {
// seed with one item first
await superPut('/update-my-experience').send({
experience: [
{
id: 'seed',
title: 'Seed Title',
company: 'Seed Co',
location: 'Seed City',
startDate: '2019-01',
endDate: '2019-12',
description: 'Seed desc'
}
]
});
const response = await superPut('/update-my-experience').send({
experience: []
});
expect(response.body).toEqual({
message: 'flash.experience-updated',
type: 'success'
});
expect(response.statusCode).toEqual(200);
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: developerUserEmail },
select: { experience: true }
});
expect(user?.experience).toEqual([]);
});
test('PUT saves multiple experiences and preserves order', async () => {
const payload = {
experience: [
{
id: '1',
title: 'Junior Dev',
company: 'A Inc',
location: 'NY',
startDate: '2018-01',
endDate: '2019-01',
description: 'Did stuff'
},
{
id: '2',
title: 'Senior Dev',
company: 'B LLC',
location: 'SF',
startDate: '2019-02',
endDate: '2021-03',
description: 'Did more stuff'
}
]
};
const response = await superPut('/update-my-experience').send(payload);
expect(response.statusCode).toEqual(200);
const user = await fastifyTestInstance.prisma.user.findFirst({
where: { email: developerUserEmail },
select: { experience: true }
});
expect(user?.experience).toEqual(payload.experience);
});
test('PUT returns 400 status code when the experience property is missing', async () => {
const response = await superPut('/update-my-experience').send({});
expect(response.body).toEqual(updateErrorResponse);
expect(response.statusCode).toEqual(400);
});
test('PUT returns 400 status code when any data is the wrong type', async () => {
const response = await superPut('/update-my-experience').send({
experience: [
{
id: '',
title: '',
company: '',
location: '',
startDate: '',
endDate: '',
description: ''
},
{
id: '',
title: {},
company: '',
location: '',
startDate: '',
endDate: '',
description: ''
}
]
});
expect(response.body).toEqual(updateErrorResponse);
expect(response.statusCode).toEqual(400);
});
});
describe('/update-my-classroom-mode', () => {
test('PUT returns 200 status code with "success" message', async () => {
const response = await superPut('/update-my-classroom-mode').send({
@@ -1263,7 +1492,8 @@ Happy coding!
{ path: '/update-my-about', method: 'PUT' },
{ path: '/update-my-honesty', method: 'PUT' },
{ path: '/update-privacy-terms', method: 'PUT' },
{ path: '/update-my-portfolio', method: 'PUT' }
{ path: '/update-my-portfolio', method: 'PUT' },
{ path: '/update-my-experience', method: 'PUT' }
];
endpoints.forEach(({ path, method }) => {
+31
View File
@@ -166,6 +166,7 @@ export const settingRoutes: FastifyPluginCallbackTypebox = (
showName: req.body.profileUI.showName,
showPoints: req.body.profileUI.showPoints,
showPortfolio: req.body.profileUI.showPortfolio,
showExperience: req.body.profileUI.showExperience,
showTimeLine: req.body.profileUI.showTimeLine
}
}
@@ -711,6 +712,36 @@ ${isLinkSentWithinLimitTTL}`
}
);
fastify.put(
'/update-my-experience',
{
schema: schemas.updateMyExperience
},
async (req, reply) => {
const logger = fastify.log.child({ req, res: reply });
try {
const { experience } = req.body;
await fastify.prisma.user.update({
where: { id: req.user?.id },
data: {
experience
}
});
return {
message: 'flash.experience-updated',
type: 'success'
} as const;
} catch (err) {
logger.error(err);
fastify.Sentry.captureException(err);
void reply.code(500);
return { message: 'flash.wrong-updating', type: 'danger' } as const;
}
}
);
fastify.put(
'/update-my-classroom-mode',
{
+3
View File
@@ -177,6 +177,7 @@ const lockedProfileUI = {
showAbout: false,
showCerts: false,
showDonation: false,
showExperience: false,
showHeatMap: false,
showLocation: false,
showName: false,
@@ -271,6 +272,7 @@ const publicUserData = {
completedExams: testUserData.completedExams,
completedSurveys: [], // TODO: add surveys
quizAttempts: testUserData.quizAttempts,
experience: [],
githubProfile: testUserData.githubProfile,
is2018DataVisCert: testUserData.is2018DataVisCert,
is2018FullStackCert: testUserData.is2018FullStackCert, // TODO: should this be returned? The client doesn't use it at the moment.
@@ -1005,6 +1007,7 @@ describe('userRoutes', () => {
completedDailyCodingChallenges: [],
completedExams: [],
completedSurveys: [],
experience: [],
partiallyCompletedChallenges: [],
portfolio: [],
savedChallenges: [],
+3
View File
@@ -722,6 +722,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
partiallyCompletedChallenges: true,
picture: true,
portfolio: true,
experience: true,
profileUI: true,
progressTimestamps: true,
savedChallenges: true,
@@ -781,6 +782,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
location,
name,
theme,
experience,
...publicUser
} = rest;
@@ -818,6 +820,7 @@ export const userGetRoutes: FastifyPluginCallbackTypebox = (
usernameDisplay: usernameDisplay || username,
userToken: encodedToken,
completedSurveys: normalizeSurveys(completedSurveys),
experience: experience.map(removeNulls),
msUsername: msUsername?.msUsername
}
},
+5 -1
View File
@@ -127,6 +127,7 @@ const lockedProfileUI = {
showAbout: false,
showCerts: false,
showDonation: false,
showExperience: false,
showHeatMap: false,
showLocation: false,
showName: false,
@@ -253,6 +254,7 @@ describe('userRoutes', () => {
showAbout: true,
showCerts: true,
showDonation: true,
showExperience: false,
showHeatMap: true,
showLocation: true,
showName: true,
@@ -485,6 +487,7 @@ describe('get-public-profile helpers', () => {
description: 'description'
}
],
experience: [],
profileUI: {
isLocked: false,
showAbout: true,
@@ -495,7 +498,8 @@ describe('get-public-profile helpers', () => {
showName: true,
showPoints: true,
showPortfolio: true,
showTimeLine: true
showTimeLine: true,
showExperience: true
}
};
+8 -3
View File
@@ -1,4 +1,4 @@
import { Portfolio } from '@prisma/client';
import { Experience, Portfolio } from '@prisma/client';
import { type FastifyPluginCallbackTypebox } from '@fastify/type-provider-typebox';
import { ObjectId } from 'mongodb';
import { omit } from 'lodash-es';
@@ -33,6 +33,7 @@ type ProfileUI = Partial<{
showName: boolean;
showPoints: boolean;
showPortfolio: boolean;
showExperience: boolean;
showTimeLine: boolean;
}>;
@@ -47,6 +48,7 @@ type RawUser = {
name: string;
points: number;
portfolio: Portfolio[];
experience: Experience[];
profileUI: ProfileUI;
};
@@ -65,6 +67,7 @@ export const replacePrivateData = (user: RawUser) => {
showName,
showPoints,
showPortfolio,
showExperience,
showTimeLine
} = user.profileUI;
@@ -83,7 +86,8 @@ export const replacePrivateData = (user: RawUser) => {
location: showLocation ? user.location : '',
name: showName ? user.name : '',
points: showPoints ? user.points : null,
portfolio: showPortfolio ? user.portfolio : []
portfolio: showPortfolio ? user.portfolio : [],
experience: showExperience ? user.experience : []
};
};
@@ -185,7 +189,8 @@ export const userPublicGetRoutes: FastifyPluginCallbackTypebox = (
joinDate: new ObjectId(user.id).getTimestamp().toISOString(),
name: user.name ?? '',
points: getPoints(progressTimestamps),
profileUI: normalizedProfileUI
profileUI: normalizedProfileUI,
experience: user.experience ?? []
});
const returnedUser = {
+1
View File
@@ -24,6 +24,7 @@ export { updateMyAbout } from './schemas/settings/update-my-about.js';
export { confirmEmail } from './schemas/settings/confirm-email.js';
export { updateMyClassroomMode } from './schemas/settings/update-my-classroom-mode.js';
export { updateMyEmail } from './schemas/settings/update-my-email.js';
export { updateMyExperience } from './schemas/settings/update-my-experience.js';
export { updateMyHonesty } from './schemas/settings/update-my-honesty.js';
export { updateMyKeyboardShortcuts } from './schemas/settings/update-my-keyboard-shortcuts.js';
export { updateMyPortfolio } from './schemas/settings/update-my-portfolio.js';
@@ -0,0 +1,34 @@
import { Type } from '@fastify/type-provider-typebox';
export const updateMyExperience = {
body: Type.Object({
experience: Type.Array(
Type.Object(
{
id: Type.String(),
title: Type.String(),
company: Type.String(),
location: Type.Optional(Type.String()),
startDate: Type.String(),
endDate: Type.Optional(Type.String()),
description: Type.String()
},
{ additionalProperties: false }
)
)
}),
response: {
200: Type.Object({
message: Type.Literal('flash.experience-updated'),
type: Type.Literal('success')
}),
400: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
}),
500: Type.Object({
message: Type.Literal('flash.wrong-updating'),
type: Type.Literal('danger')
})
}
};
@@ -12,6 +12,7 @@ export const updateMyProfileUI = {
showName: Type.Boolean(),
showPoints: Type.Boolean(),
showPortfolio: Type.Boolean(),
showExperience: Type.Boolean(),
showTimeLine: Type.Boolean()
})
}),
+12 -1
View File
@@ -79,5 +79,16 @@ export const profileUI = Type.Object({
showName: Type.Optional(Type.Boolean()),
showPoints: Type.Optional(Type.Boolean()),
showPortfolio: Type.Optional(Type.Boolean()),
showTimeLine: Type.Optional(Type.Boolean())
showTimeLine: Type.Optional(Type.Boolean()),
showExperience: Type.Optional(Type.Boolean())
});
export const experience = Type.Object({
id: Type.String(),
title: Type.String(),
company: Type.String(),
location: Type.Optional(Type.String()),
startDate: Type.String(),
endDate: Type.Optional(Type.String()),
description: Type.String()
});
+7 -1
View File
@@ -1,5 +1,10 @@
import { Type } from '@fastify/type-provider-typebox';
import { examResults, profileUI, savedChallenge } from '../types.js';
import {
examResults,
profileUI,
savedChallenge,
experience
} from '../types.js';
const languages = Type.Array(
Type.Union([Type.Literal('javascript'), Type.Literal('python')])
@@ -118,6 +123,7 @@ export const getSessionUser = {
url: Type.String()
})
),
experience: Type.Optional(Type.Array(experience)),
profileUI: Type.Optional(profileUI),
sendQuincyEmail: Type.Union([Type.Null(), Type.Boolean()]), // // Tri-state: null (likely new user), true (subscribed), false (unsubscribed)
theme: Type.String(),
+1
View File
@@ -87,6 +87,7 @@ export function createUserInput(email: string) {
showAbout: false,
showCerts: false,
showDonation: false,
showExperience: false,
showHeatMap: false,
showLocation: false,
showName: false,
+8 -4
View File
@@ -50,7 +50,8 @@ describe('normalize', () => {
showName: true,
showPoints: true,
showPortfolio: true,
showTimeLine: true
showTimeLine: true,
showExperience: true
};
const defaultProfileUI = {
@@ -63,7 +64,8 @@ describe('normalize', () => {
showName: false,
showPoints: false,
showPortfolio: false,
showTimeLine: false
showTimeLine: false,
showExperience: false
};
describe('normalizeProfileUI', () => {
@@ -87,7 +89,8 @@ describe('normalize', () => {
showName: null,
showPoints: null,
showPortfolio: null,
showTimeLine: null
showTimeLine: null,
showExperience: null
};
expect(normalizeProfileUI(input)).toEqual({
isLocked: undefined,
@@ -99,7 +102,8 @@ describe('normalize', () => {
showName: undefined,
showPoints: undefined,
showPortfolio: undefined,
showTimeLine: undefined
showTimeLine: undefined,
showExperience: undefined
});
});
});
+2 -1
View File
@@ -137,7 +137,8 @@ export const normalizeProfileUI = (
showName: false,
showPoints: false,
showPortfolio: false,
showTimeLine: false
showTimeLine: false,
showExperience: false
};
};
+25 -1
View File
@@ -323,6 +323,7 @@
"my-heatmap": "My heatmap",
"my-certs": "My certifications",
"my-portfolio": "My portfolio",
"my-experience": "My experience",
"my-timeline": "My timeline",
"my-donations": "My donations",
"night-mode": "Night Mode",
@@ -435,7 +436,24 @@
"page-number": "{{pageNumber}} of {{totalPages}}",
"edit-my-profile": "Edit My Profile",
"add-bluesky": "Share this certification on BlueSky",
"add-threads": "Share this certification on Threads"
"add-threads": "Share this certification on Threads",
"experience": {
"heading": "Experience",
"share-experience": "Share your professional experience",
"add": "Add experience",
"save": "Save experience",
"remove": "Remove experience",
"job-title": "Job title",
"company": "Company",
"location": "Location",
"start-date": "Start date",
"end-date": "End date",
"end-date-helper": "Leave blank if current position",
"description": "Description",
"present": "Present",
"date-format-error": "Please enter the date in MM/YYYY format.",
"date-invalid": "Please enter a valid date."
}
},
"footer": {
"tax-exempt-status": "freeCodeCamp is a donor-supported tax-exempt 501(c)(3) charitable organization (United States Federal Tax Identification Number: 82-0779546).",
@@ -990,6 +1008,7 @@
"keyboard-shortcut-updated": "We have updated your keyboard shortcuts settings",
"subscribe-to-quincy-updated": "We have updated your subscription to Quincy's email",
"portfolio-item-updated": "We have updated your portfolio",
"experience-updated": "We have updated your experience",
"email-invalid": "Email format is invalid",
"email-valid": "Your email has successfully been changed, happy coding!",
"bad-challengeId": "currentChallengeId is not a valid challenge ID",
@@ -1090,12 +1109,17 @@
},
"validation": {
"max-characters": "There is a maximum limit of 288 characters, you have {{charsLeft}} left",
"max-characters-500": "There is a maximum limit of 500 characters, you have {{charsLeft}} left",
"same-email": "This email is the same as your current email",
"invalid-email": "We could not validate your email correctly, please ensure it is correct",
"email-mismatch": "Both new email addresses must be the same",
"title-required": "A title is required",
"title-short": "Title is too short",
"title-long": "Title is too long",
"company-required": "Company is required",
"company-short": "Company name is too short",
"company-long": "Company name is too long",
"start-date-required": "Start date is required",
"invalid-url": "We could not validate your URL correctly, please ensure it is correct",
"invalid-protocol": "URL must start with http or https",
"url-not-image": "URL must link directly to an image file",
+1
View File
@@ -5,3 +5,4 @@ export { default as Link } from './link';
export { default as LazyImage } from './lazy-image';
export { default as AvatarRenderer } from './avatar-renderer';
export { ButtonLink } from './button-link';
export { interleave } from './interleave';
@@ -0,0 +1,19 @@
/**
* Interleaves an array of items with a separator element between each item.
* @param items - The array of items to interleave
* @param separator - A function that returns the separator element for each position
* @returns An array with separators inserted between items
*/
export function interleave<T>(
items: T[],
separator: (index: number) => T
): T[] {
const result: T[] = [];
items.forEach((item, index) => {
result.push(item);
if (index < items.length - 1) {
result.push(separator(index));
}
});
return result;
}
@@ -0,0 +1,15 @@
.experience-company {
font-weight: normal;
margin-top: 0.5rem;
}
.experience-date {
color: #858591;
font-size: 0.9rem;
margin-top: 0.25rem;
}
.experience-description {
margin-top: 0.75rem;
white-space: pre-wrap;
}
@@ -0,0 +1,61 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Spacer } from '@freecodecamp/ui';
import { parse, format, isValid } from 'date-fns';
import type { ExperienceData } from '../../../redux/prop-types';
import { FullWidthRow, interleave } from '../../helpers';
import './experience-display.css';
interface ExperienceDisplayProps {
experience: ExperienceData[];
}
const formatDate = (dateString: string): string => {
if (!dateString) return '';
const parsedDate = parse(dateString, 'MM/yyyy', new Date());
if (!isValid(parsedDate)) return '';
return format(parsedDate, 'MMM yyyy');
};
export const ExperienceDisplay = ({
experience
}: ExperienceDisplayProps): JSX.Element | null => {
const { t } = useTranslation();
if (!experience.length) {
return null;
}
const experienceItems = experience.map(exp => (
<div key={exp.id} className='experience-item'>
<h3>{exp.title}</h3>
<h4 className='experience-company'>
{exp.company}
{exp.location && `${exp.location}`}
</h4>
<p className='experience-date'>
{formatDate(exp.startDate)}
{' - '}
{exp.endDate
? formatDate(exp.endDate)
: t('profile.experience.present')}
</p>
{exp.description && (
<p className='experience-description'>{exp.description}</p>
)}
</div>
));
return (
<FullWidthRow>
<section className='card'>
<h2>{t('profile.experience.heading')}</h2>
<Spacer size='s' />
{interleave(experienceItems, index => (
<hr key={`separator-${index}`} />
))}
<Spacer size='m' />
</section>
</FullWidthRow>
);
};
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import { validateDate } from './experience';
describe('validateDate', () => {
it('should return error for required empty date', () => {
const result = validateDate({
date: '',
isRequired: true,
fieldName: 'start-date'
});
expect(result).toEqual({
state: 'error',
messageKey: 'validation.start-date-required'
});
});
it('should return success for optional empty date', () => {
const result = validateDate({
date: '',
isRequired: false,
fieldName: 'end-date'
});
expect(result).toEqual({
state: 'success',
messageKey: ''
});
});
it('should return error for invalid format', () => {
const result = validateDate({
date: '01/01',
isRequired: true,
fieldName: 'start-date'
});
expect(result).toEqual({
state: 'error',
messageKey: 'profile.experience.date-format-error'
});
});
it('should return error for invalid date', () => {
const result = validateDate({
date: '13/2023',
isRequired: true,
fieldName: 'start-date'
});
expect(result).toEqual({
state: 'error',
messageKey: 'profile.experience.date-invalid'
});
});
it('should return success for valid date', () => {
const result = validateDate({
date: '12/2023',
isRequired: true,
fieldName: 'start-date'
});
expect(result).toEqual({
state: 'success',
messageKey: ''
});
});
it('should return error for required empty date with end-date', () => {
const result = validateDate({
date: '',
isRequired: true,
fieldName: 'end-date'
});
expect(result).toEqual({
state: 'error',
messageKey: 'validation.end-date-required'
});
});
});
@@ -0,0 +1,482 @@
import { isEqual } from 'lodash-es';
import { nanoid } from 'nanoid';
import React, { useState } from 'react';
import type { TFunction } from 'i18next';
import { isValid, parse } from 'date-fns';
import {
FormGroup,
FormControl,
ControlLabel,
HelpBlock,
FormGroupProps,
Button,
Spacer
} from '@freecodecamp/ui';
import { withTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { ExperienceData } from '../../../redux/prop-types';
import { updateMyExperience } from '../../../redux/settings/actions';
import { FullWidthRow, interleave } from '../../helpers';
import BlockSaveButton from '../../helpers/form/block-save-button';
import SectionHeader from '../../settings/section-header';
type ExperienceProps = {
experience: ExperienceData[];
t: TFunction;
updateMyExperience: (obj: { experience: ExperienceData[] }) => void;
};
interface ExperienceValidation {
state: FormGroupProps['validationState'];
message: string;
}
interface ValidationResult {
state: FormGroupProps['validationState'];
messageKey: string;
}
const mapDispatchToProps = {
updateMyExperience
};
export const validateDate = ({
date,
isRequired,
fieldName
}: {
date: string;
isRequired: boolean;
fieldName: 'start-date' | 'end-date';
}): ValidationResult => {
// Check if date is required and empty
if (isRequired && !date) {
return { state: 'error', messageKey: `validation.${fieldName}-required` };
}
// Allow empty for optional dates
if (!date) {
return { state: 'success', messageKey: '' };
}
// Check if date matches MM/YYYY format
const dateRegex = /^\d{2}\/\d{4}$/;
if (!dateRegex.test(date)) {
return {
state: 'error',
messageKey: 'profile.experience.date-format-error'
};
}
const parsedDate = parse(date, 'MM/yyyy', new Date());
// Check if the parsed date is valid (e.g., not an invalid month like 13)
if (!isValid(parsedDate)) {
return {
state: 'error',
messageKey: 'profile.experience.date-invalid'
};
}
return { state: 'success', messageKey: '' };
};
function createEmptyExperienceItem(): ExperienceData {
return {
id: nanoid(),
title: '',
company: '',
location: '',
startDate: '',
endDate: '',
description: ''
};
}
const byId = (id: string) => (exp: ExperienceData) => exp.id === id;
const notById = (id: string) => (exp: ExperienceData) => exp.id !== id;
const ExperienceSettings = (props: ExperienceProps) => {
const { t, experience: initialExperience = [], updateMyExperience } = props;
const [experience, setExperience] = useState(initialExperience);
const [newItemId, setNewItemId] = useState<string | null>(null);
const createOnChangeHandler =
(
id: string,
key:
| 'title'
| 'company'
| 'location'
| 'startDate'
| 'endDate'
| 'description'
) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
e.preventDefault();
const userInput = e.target.value.slice();
setExperience(prevExperience => {
return prevExperience.map(exp =>
byId(id)(exp) ? { ...exp, [key]: userInput } : exp
);
});
};
const saveItem = (id: string) => {
if (newItemId === id) {
setNewItemId(null);
}
const itemToSave = experience.find(byId(id));
if (itemToSave && isItemValid(itemToSave)) {
const itemIndex = props.experience.findIndex(byId(id));
const updatedExperience =
itemIndex >= 0
? props.experience.map(item => (byId(id)(item) ? itemToSave : item))
: [itemToSave, ...props.experience];
updateMyExperience({ experience: updatedExperience });
}
};
const handleAdd = () => {
const item = createEmptyExperienceItem();
setExperience(prev => [item, ...prev]);
setNewItemId(item.id);
};
const handleRemoveItem = (id: string) => {
setExperience(experience.filter(notById(id)));
if (newItemId === id) {
setNewItemId(null);
}
const filteredExperience = props.experience.filter(notById(id));
updateMyExperience({ experience: filteredExperience });
};
const isFormPristine = (id: string) => {
const original = props.experience.find(byId(id));
if (!original) {
return false;
}
const edited = experience.find(byId(id));
return isEqual(original, edited);
};
const getDescriptionValidation = (
description: string
): ExperienceValidation => {
const len = description.length;
const charsLeft = 500 - len;
if (charsLeft < 0) {
return {
state: 'error',
message: t('validation.max-characters-500', { charsLeft: 0 })
};
}
if (charsLeft < 41 && charsLeft > 0) {
return {
state: 'warning',
message: t('validation.max-characters-500', { charsLeft })
};
}
if (charsLeft === 500) {
return { state: null, message: '' };
}
return { state: 'success', message: '' };
};
const getTextValidation = (
value: string,
field: 'title' | 'company'
): ExperienceValidation => {
if (!value) {
return { state: 'error', message: t(`validation.${field}-required`) };
}
const len = value.length;
if (len < 2) {
return { state: 'error', message: t(`validation.${field}-short`) };
}
if (len > 144) {
return { state: 'error', message: t(`validation.${field}-long`) };
}
return { state: 'success', message: '' };
};
const getTitleValidation = (title: string) =>
getTextValidation(title, 'title');
const getCompanyValidation = (company: string) =>
getTextValidation(company, 'company');
const getStartDateValidation = (startDate: string): ExperienceValidation => {
const result = validateDate({
date: startDate,
isRequired: true,
fieldName: 'start-date'
});
return {
state: result.state,
message: result.messageKey ? t(result.messageKey) : ''
};
};
const getEndDateValidation = (endDate: string): ExperienceValidation => {
const result = validateDate({
date: endDate,
isRequired: false,
fieldName: 'end-date'
});
return {
state: result.state,
message: result.messageKey ? t(result.messageKey) : ''
};
};
const isItemValid = (experienceItem: ExperienceData): boolean => {
const { title, company, startDate, endDate, description } = experienceItem;
return (
getTitleValidation(title).state !== 'error' &&
getCompanyValidation(company).state !== 'error' &&
getStartDateValidation(startDate).state !== 'error' &&
getEndDateValidation(endDate || '').state !== 'error' &&
getDescriptionValidation(description).state !== 'error'
);
};
const getFormValidation = (experienceItem: ExperienceData) => {
const { id, title, company, startDate, endDate, description } =
experienceItem;
const { state: titleState, message: titleMessage } =
getTitleValidation(title);
const { state: companyState, message: companyMessage } =
getCompanyValidation(company);
const { state: startDateState, message: startDateMessage } =
getStartDateValidation(startDate);
const { state: endDateState, message: endDateMessage } =
getEndDateValidation(endDate || '');
const { state: descriptionState, message: descriptionMessage } =
getDescriptionValidation(description);
const pristine = isFormPristine(id);
const isButtonDisabled = !isItemValid(experienceItem);
return {
isButtonDisabled,
title: { titleState, titleMessage },
company: { companyState, companyMessage },
startDate: { startDateState, startDateMessage },
endDate: { endDateState, endDateMessage },
description: { descriptionState, descriptionMessage },
pristine
};
};
const renderExperience = (experienceItem: ExperienceData) => {
const { id, title, company, location, startDate, endDate, description } =
experienceItem;
const {
isButtonDisabled,
title: { titleState, titleMessage },
company: { companyState, companyMessage },
startDate: { startDateState, startDateMessage },
endDate: { endDateState, endDateMessage },
description: { descriptionState, descriptionMessage },
pristine
} = getFormValidation(experienceItem);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>, id: string) => {
e.preventDefault();
if (isButtonDisabled) return null;
return saveItem(id);
};
return (
<FullWidthRow key={id}>
<form onSubmit={e => handleSubmit(e, id)}>
<FormGroup
controlId={`${id}-title`}
validationState={
pristine || (!pristine && !title) ? null : titleState
}
>
<ControlLabel htmlFor={`${id}-title-input`}>
{t('profile.experience.job-title')}{' '}
<span aria-hidden='true'>*</span>
</ControlLabel>
<FormControl
onChange={createOnChangeHandler(id, 'title')}
required
type='text'
value={title}
name='experience-title'
id={`${id}-title-input`}
aria-describedby={titleMessage ? `${id}-title-error` : undefined}
/>
{titleMessage ? (
<HelpBlock id={`${id}-title-error`}>{titleMessage}</HelpBlock>
) : null}
</FormGroup>
<FormGroup
controlId={`${id}-company`}
validationState={
pristine || (!pristine && !company) ? null : companyState
}
>
<ControlLabel htmlFor={`${id}-company-input`}>
{t('profile.experience.company')}{' '}
<span aria-hidden='true'>*</span>
</ControlLabel>
<FormControl
onChange={createOnChangeHandler(id, 'company')}
required
type='text'
value={company}
name='experience-company'
id={`${id}-company-input`}
aria-describedby={
companyMessage ? `${id}-company-error` : undefined
}
/>
{companyMessage ? (
<HelpBlock id={`${id}-company-error`}>{companyMessage}</HelpBlock>
) : null}
</FormGroup>
<FormGroup controlId={`${id}-location`}>
<ControlLabel htmlFor={`${id}-location-input`}>
{t('profile.experience.location')}
</ControlLabel>
<FormControl
onChange={createOnChangeHandler(id, 'location')}
type='text'
value={location || ''}
name='experience-location'
id={`${id}-location-input`}
/>
</FormGroup>
<FormGroup
controlId={`${id}-startDate`}
validationState={
pristine || (!pristine && !startDate) ? null : startDateState
}
>
<ControlLabel htmlFor={`${id}-startDate-input`}>
{t('profile.experience.start-date')}{' '}
<span aria-hidden='true'>*</span>
</ControlLabel>
<FormControl
onChange={createOnChangeHandler(id, 'startDate')}
required
type='text'
value={startDate}
name='experience-startDate'
id={`${id}-startDate-input`}
placeholder='MM/YYYY'
aria-describedby={
startDateMessage ? `${id}-startDate-error` : undefined
}
/>
{startDateMessage ? (
<HelpBlock id={`${id}-startDate-error`}>
{startDateMessage}
</HelpBlock>
) : null}
</FormGroup>
<FormGroup
controlId={`${id}-endDate`}
validationState={
pristine || (!pristine && !endDate) ? null : endDateState
}
>
<ControlLabel htmlFor={`${id}-endDate-input`}>
{t('profile.experience.end-date')} (
{t('profile.experience.end-date-helper')})
</ControlLabel>
<FormControl
onChange={createOnChangeHandler(id, 'endDate')}
type='text'
value={endDate || ''}
name='experience-endDate'
id={`${id}-endDate-input`}
aria-describedby={
endDateMessage ? `${id}-endDate-error` : undefined
}
/>
{endDateMessage ? (
<HelpBlock id={`${id}-endDate-error`}>{endDateMessage}</HelpBlock>
) : null}
</FormGroup>
<FormGroup
controlId={`${id}-description`}
validationState={pristine ? null : descriptionState}
>
<ControlLabel htmlFor={`${id}-description-input`}>
{t('profile.experience.description')}{' '}
<span aria-hidden='true'>*</span>
</ControlLabel>
<FormControl
componentClass='textarea'
onChange={createOnChangeHandler(id, 'description')}
required
value={description}
name='experience-description'
id={`${id}-description-input`}
aria-describedby={
descriptionMessage ? `${id}-description-error` : undefined
}
/>
{descriptionMessage ? (
<HelpBlock id={`${id}-description-error`}>
{descriptionMessage}
</HelpBlock>
) : null}
</FormGroup>
<BlockSaveButton
disabled={isButtonDisabled || pristine}
bgSize='large'
{...((isButtonDisabled || pristine) && { tabIndex: -1 })}
>
{t('profile.experience.save')}
</BlockSaveButton>
<Spacer size='xs' />
<Button
block
size='large'
variant='danger'
onClick={() => handleRemoveItem(id)}
type='button'
>
{t('profile.experience.remove')}
</Button>
</form>
</FullWidthRow>
);
};
return (
<section id='experience-settings'>
<SectionHeader>{t('profile.experience.heading')}</SectionHeader>
<FullWidthRow>
<p>{t('profile.experience.share-experience')}</p>
<Spacer size='xs' />
<Button
block
size='large'
variant='primary'
disabled={newItemId !== null}
onClick={handleAdd}
type='button'
>
{t('profile.experience.add')}
</Button>
</FullWidthRow>
<Spacer size='l' />
{interleave(experience.map(renderExperience), () => (
<>
<Spacer size='m' />
<hr />
<Spacer size='m' />
</>
))}
</section>
);
};
ExperienceSettings.displayName = 'ExperienceSettings';
export default withTranslation()(
connect(null, mapDispatchToProps)(ExperienceSettings)
);
+11 -2
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { Callout, Container, Modal, Row, Spacer } from '@freecodecamp/ui';
import { FullWidthRow, Link } from '../helpers';
import Portfolio from './components/portfolio';
import Experience from './components/experience';
import UsernameSettings from './components/username';
import About from './components/about';
@@ -17,6 +18,7 @@ import Stats from './components/stats';
import HeatMap from './components/heat-map';
import './profile.css';
import { PortfolioProjects } from './components/portfolio-projects';
import { ExperienceDisplay } from './components/experience-display';
interface ProfileProps {
isSessionUser: boolean;
@@ -47,7 +49,7 @@ const UserMessage = ({ t }: Pick<MessageProps, 't'>) => {
};
const EditModal = ({ user, isEditing, setIsEditing }: EditModalProps) => {
const { portfolio, username } = user;
const { portfolio, experience, username } = user;
const { t } = useTranslation();
return (
<Modal onClose={() => setIsEditing(false)} open={isEditing} size='large'>
@@ -60,6 +62,8 @@ const EditModal = ({ user, isEditing, setIsEditing }: EditModalProps) => {
<Internet user={user} setIsEditing={setIsEditing} />
<Spacer size='m' />
<Portfolio portfolio={portfolio} setIsEditing={setIsEditing} />
<Spacer size='m' />
<Experience experience={experience || []} />
</Modal.Body>
</Modal>
);
@@ -95,13 +99,15 @@ function UserProfile({ user, isSessionUser }: ProfileProps): JSX.Element {
showHeatMap,
showPoints,
showPortfolio,
showExperience,
showTimeLine
},
calendar,
completedChallenges,
username,
points,
portfolio
portfolio,
experience
} = user;
return (
@@ -124,6 +130,9 @@ function UserProfile({ user, isSessionUser }: ProfileProps): JSX.Element {
{showPortfolio ? (
<PortfolioProjects portfolioProjects={portfolio} />
) : null}
{showExperience ? (
<ExperienceDisplay experience={experience || []} />
) : null}
{showCerts ? <Certifications user={user} /> : null}
{showTimeLine ? (
<Timeline completedMap={completedChallenges} username={username} />
+11 -1
View File
@@ -32,7 +32,9 @@ type PrivacyProps = {
function PrivacySettings({ submitProfileUI, user }: PrivacyProps): JSX.Element {
const { t } = useTranslation();
const [privacyValues, setPrivacyValues] = useState({ ...user.profileUI });
const [privacyValues, setPrivacyValues] = useState({
...user.profileUI
});
const [madeChanges, setMadeChanges] = useState(false);
@@ -124,6 +126,14 @@ function PrivacySettings({ submitProfileUI, user }: PrivacyProps): JSX.Element {
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showPortfolio')}
/>
<ToggleRadioSetting
action={t('settings.labels.my-experience')}
flag={!privacyValues['showExperience']}
flagName='showExperience'
offLabel={t('buttons.public')}
onLabel={t('buttons.private')}
toggleFlag={toggleFlag('showExperience')}
/>
<ToggleRadioSetting
action={t('settings.labels.my-timeline')}
explain={t('settings.disabled')}
+2
View File
@@ -497,6 +497,8 @@ export const reducer = handleActions(
payload ? spreadThePayloadOnUser(state, payload) : state,
[settingsTypes.updateMyPortfolioComplete]: (state, { payload }) =>
payload ? spreadThePayloadOnUser(state, payload) : state,
[settingsTypes.updateMyExperienceComplete]: (state, { payload }) =>
payload ? spreadThePayloadOnUser(state, payload) : state,
[settingsTypes.resetMyEditorLayoutComplete]: (state, { payload }) =>
payload ? spreadThePayloadOnUser(state, payload) : state,
[settingsTypes.verifyCertComplete]: (state, { payload }) =>
+12
View File
@@ -437,6 +437,7 @@ export type User = {
picture: string;
points: number;
portfolio: PortfolioProjectData[];
experience?: ExperienceData[];
profileUI: ProfileUI;
progressTimestamps: Array<unknown>;
savedChallenges: SavedChallenges;
@@ -461,6 +462,7 @@ export type ProfileUI = {
showName: boolean;
showPoints: boolean;
showPortfolio: boolean;
showExperience: boolean;
showTimeLine: boolean;
};
@@ -526,6 +528,16 @@ export type PortfolioProjectData = {
description: string;
};
export type ExperienceData = {
id: string;
title: string;
company: string;
location?: string;
startDate: string;
endDate?: string;
description: string;
};
export type FileKeyChallenge = {
contents: string;
ext: Ext;
@@ -14,6 +14,7 @@ export const actionTypes = createTypes(
...createAsyncTypes('updateMyHonesty'),
...createAsyncTypes('updateMyQuincyEmail'),
...createAsyncTypes('updateMyPortfolio'),
...createAsyncTypes('updateMyExperience'),
...createAsyncTypes('submitProfileUI'),
...createAsyncTypes('verifyCert'),
...createAsyncTypes('resetProgress'),
+9
View File
@@ -82,6 +82,15 @@ export const updateMyPortfolioError = createAction(
types.updateMyPortfolioError
);
export const updateMyExperience = createAction(types.updateMyExperience);
export const updateMyExperienceComplete = createAction(
types.updateMyExperienceComplete,
checkForSuccessPayload
);
export const updateMyExperienceError = createAction(
types.updateMyExperienceError
);
export const validateUsername = createAction(types.validateUsername);
export const validateUsernameComplete = createAction(
types.validateUsernameComplete
@@ -19,6 +19,7 @@ import {
putUpdateMyHonesty,
putUpdateMyKeyboardShortcuts,
putUpdateMyPortfolio,
putUpdateMyExperience,
putUpdateMyProfileUI,
putUpdateMyQuincyEmail,
putUpdateMySocials,
@@ -41,6 +42,8 @@ import {
updateMyKeyboardShortcutsError,
updateMyPortfolioComplete,
updateMyPortfolioError,
updateMyExperienceComplete,
updateMyExperienceError,
updateMyQuincyEmailComplete,
updateMyQuincyEmailError,
updateMySocialsComplete,
@@ -169,6 +172,16 @@ function* updateMyPortfolioSaga({ payload: update }) {
}
}
function* updateMyExperienceSaga({ payload: update }) {
try {
const { data } = yield call(putUpdateMyExperience, update);
yield put(updateMyExperienceComplete({ ...data, payload: update }));
yield put(createFlashMessage({ ...data }));
} catch {
yield put(updateMyExperienceError);
}
}
function* validateUsernameSaga({ payload }) {
try {
const {
@@ -238,6 +251,7 @@ export function createSettingsSagas(types) {
takeEvery(types.updateMyKeyboardShortcuts, updateMyKeyboardShortcutsSaga),
takeEvery(types.updateMyQuincyEmail, updateMyQuincyEmailSaga),
takeEvery(types.updateMyPortfolio, updateMyPortfolioSaga),
takeEvery(types.updateMyExperience, updateMyExperienceSaga),
takeLatest(types.submitNewAbout, submitNewAboutSaga),
takeLatest(types.submitNewUsername, submitNewUsernameSaga),
debounce(2000, types.validateUsername, validateUsernameSaga),
+6
View File
@@ -409,6 +409,12 @@ export function putUpdateMyPortfolio(
return put('/update-my-portfolio', update);
}
export function putUpdateMyExperience(
update: Record<string, string>
): Promise<ResponseWithData<void>> {
return put('/update-my-experience', update);
}
export function putUserUpdateEmail(
email: string
): Promise<ResponseWithData<void>> {
+1
View File
@@ -16,6 +16,7 @@ const unlockedProfile = {
showName: true,
showPoints: true,
showPortfolio: true,
showExperience: true,
showTimeLine: true
};
+138
View File
@@ -0,0 +1,138 @@
import { execSync } from 'child_process';
import { test, expect } from '@playwright/test';
test.use({ storageState: 'playwright/.auth/development-user.json' });
test.beforeAll(() => {
execSync('node ../tools/scripts/seed/seed-demo-user');
});
test.afterAll(() => {
execSync('node ../tools/scripts/seed/seed-demo-user --certified-user');
});
test.describe('Add Experience Item', () => {
test.skip(({ browserName }) => browserName === 'webkit', 'flaky on Safari');
test.beforeEach(async ({ page }) => {
await page.goto('/developmentuser');
if (!process.env.CI) {
await page
.getByRole('button', { name: 'Preview custom 404 page' })
.click();
}
await page.getByRole('button', { name: 'Edit my profile' }).click();
await expect(async () => {
const addExperienceItemButton = page.getByRole('button', {
name: 'Add experience'
});
await addExperienceItemButton.click();
await expect(addExperienceItemButton).toBeDisabled({ timeout: 1 });
}).toPass();
});
test('The company has validation', async ({ page }) => {
await page.getByLabel('Company').fill('A');
await expect(page.getByText('Company name is too short')).toBeVisible();
await page
.getByLabel('Company')
.fill(
'This is the longest company name you will ever see in your entire life, you will never see such a long company name again. This is the longest company name in existen'
);
await expect(page.getByText('Company name is too long')).toBeVisible();
await page.getByLabel('Company').fill('freeCodeCamp');
await expect(page.getByText('Company name is too short')).toBeHidden();
await expect(page.getByText('Company name is too long')).toBeHidden();
});
test('The position has validation', async ({ page }) => {
await page.getByLabel('Job Title').fill('A');
await expect(page.getByText('Title is too short')).toBeVisible();
await page
.getByLabel('Job Title')
.fill(
'This is the longest position you will ever see in your entire life, you will never see such a long position again. This is the longest position in existen'
);
await expect(page.getByText('Title is too long')).toBeVisible();
await page.getByLabel('Job Title').fill('Software Engineer');
await expect(page.getByText('Title is too short')).toBeHidden();
await expect(page.getByText('Title is too long')).toBeHidden();
});
test('The start date has validation', async ({ page }) => {
await page.getByLabel('Start Date').fill('13/2023');
await expect(page.getByText('Please enter a valid date.')).toBeVisible();
await page.getByLabel('Start Date').fill('01/2023');
await expect(page.getByText('Please enter a valid date.')).toBeHidden();
});
test('The end date has validation', async ({ page }) => {
await page.getByLabel('End Date', { exact: false }).fill('13/2023');
await expect(page.getByText('Please enter a valid date.')).toBeVisible();
await page.getByLabel('End Date', { exact: false }).fill('01/2023');
await expect(page.getByText('Please enter a valid date.')).toBeHidden();
});
test('The description has validation', async ({ page }) => {
await page.getByLabel('Description').fill('A'.repeat(1001));
await expect(
page.getByText(
'There is a maximum limit of 500 characters, you have 0 left'
)
).toBeVisible();
await page.getByLabel('Description').fill('Worked on various projects');
await expect(
page.getByText(
'There is a maximum limit of 500 characters, you have 0 left'
)
).toBeHidden();
});
test('It should be possible to delete an experience item', async ({
page
}) => {
await page.getByLabel('Company').fill('freeCodeCamp');
await page.getByLabel('Job Title').fill('Software Engineer');
await page.getByLabel('Start Date').fill('01/2020');
await page.getByLabel('End Date', { exact: false }).fill('01/2021');
// Use locator to avoid conflict with About section's Location field
await page.locator('input[name="experience-location"]').fill('Remote');
await page.getByLabel('Description').fill('Worked on various projects');
await page.getByRole('button', { name: 'Remove Experience' }).click();
await page.getByRole('button', { name: 'Close' }).click();
await expect(page.getByRole('alert').first()).toContainText(
/We have updated your experience/
);
});
test('It should be possible to add an experience item', async ({ page }) => {
await expect(
page.getByRole('button', { name: 'Add experience' })
).toBeDisabled();
await page.getByLabel('Company').fill('freeCodeCamp');
await page.getByLabel('Job Title').fill('Software Engineer');
await page.getByLabel('Start Date').fill('01/2020');
await page.getByLabel('End Date', { exact: false }).fill('01/2021');
// Use locator to avoid conflict with About section's Location field
await page.locator('input[name="experience-location"]').fill('Remote');
await page.getByLabel('Description').fill('Worked on various projects');
await page.getByRole('button', { name: 'Save experience' }).click();
await page.getByRole('button', { name: 'Close' }).click();
await expect(page.getByRole('alert').first()).toContainText(
/We have updated your experience/
);
});
});
+10 -2
View File
@@ -157,12 +157,20 @@ test.describe('Settings - Certified User', () => {
.locator('p')
.filter({ hasText: translations.settings.labels['my-portfolio'] })
).toBeVisible();
await expect(
page
.getByRole('group', {
name: translations.settings.labels['my-experience']
})
.locator('p')
.filter({ hasText: translations.settings.labels['my-experience'] })
).toBeVisible();
await expect(
page.getByText(settingsObject.private, { exact: true })
).toHaveCount(10);
).toHaveCount(11);
await expect(
page.getByText(settingsObject.public, { exact: true })
).toHaveCount(10);
).toHaveCount(11);
const saveButton = page.getByRole('button', {
name: translations.settings.headings.privacy
});
+8
View File
@@ -53,6 +53,7 @@ module.exports.blankUser = {
isFoundationalCSharpCertV8: false,
completedChallenges: [],
portfolio: [],
experience: [],
yearsTopContributor: [],
rand: 0.6126749173148205,
theme: 'default',
@@ -66,6 +67,7 @@ module.exports.blankUser = {
showName: false,
showPoints: false,
showPortfolio: false,
showExperience: false,
showTimeLine: false
},
badges: {
@@ -115,6 +117,7 @@ module.exports.publicUser = {
isFoundationalCSharpCertV8: false,
completedChallenges: [],
portfolio: [],
experience: [],
yearsTopContributor: [],
rand: 0.6126749173148205,
theme: 'default',
@@ -128,6 +131,7 @@ module.exports.publicUser = {
showName: true,
showPoints: true,
showPortfolio: true,
showExperience: true,
showTimeLine: true
},
badges: {
@@ -178,6 +182,7 @@ module.exports.demoUser = {
isJsAlgoDataStructCertV8: false,
completedChallenges: [],
portfolio: [],
experience: [],
yearsTopContributor: [],
rand: 0.6126749173148205,
theme: 'default',
@@ -191,6 +196,7 @@ module.exports.demoUser = {
showName: false,
showPoints: false,
showPortfolio: false,
showExperience: false,
showTimeLine: false
},
badges: {
@@ -12310,6 +12316,7 @@ module.exports.fullyCertifiedUser = {
}
],
portfolio: [],
experience: [],
yearsTopContributor: ['2019'],
rand: 0.6126749173148205,
theme: 'default',
@@ -12324,6 +12331,7 @@ module.exports.fullyCertifiedUser = {
showName: true,
showPoints: true,
showPortfolio: true,
showExperience: true,
showTimeLine: true
},
badges: {