mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 18:26:54 +00:00
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:
+22
-10
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -87,6 +87,7 @@ export function createUserInput(email: string) {
|
||||
showAbout: false,
|
||||
showCerts: false,
|
||||
showDonation: false,
|
||||
showExperience: false,
|
||||
showHeatMap: false,
|
||||
showLocation: false,
|
||||
showName: false,
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,7 +137,8 @@ export const normalizeProfileUI = (
|
||||
showName: false,
|
||||
showPoints: false,
|
||||
showPortfolio: false,
|
||||
showTimeLine: false
|
||||
showTimeLine: false,
|
||||
showExperience: false
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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} />
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -16,6 +16,7 @@ const unlockedProfile = {
|
||||
showName: true,
|
||||
showPoints: true,
|
||||
showPortfolio: true,
|
||||
showExperience: true,
|
||||
showTimeLine: true
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user