diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index cd51e66c79f..a97e8c174f5 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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. diff --git a/api/src/app.ts b/api/src/app.ts index 90247d0ff3c..737c80587cf 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -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`. diff --git a/api/src/plugins/__fixtures__/user.ts b/api/src/plugins/__fixtures__/user.ts index b9ac8e07be8..c8eb2ace7f3 100644 --- a/api/src/plugins/__fixtures__/user.ts +++ b/api/src/plugins/__fixtures__/user.ts @@ -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, diff --git a/api/src/routes/protected/settings.test.ts b/api/src/routes/protected/settings.test.ts index 6f9d304471e..bb42d14a241 100644 --- a/api/src/routes/protected/settings.test.ts +++ b/api/src/routes/protected/settings.test.ts @@ -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 }) => { diff --git a/api/src/routes/protected/settings.ts b/api/src/routes/protected/settings.ts index 0a22c7f481c..530990db1b0 100644 --- a/api/src/routes/protected/settings.ts +++ b/api/src/routes/protected/settings.ts @@ -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', { diff --git a/api/src/routes/protected/user.test.ts b/api/src/routes/protected/user.test.ts index 3efd18eb4a4..1c73232aaad 100644 --- a/api/src/routes/protected/user.test.ts +++ b/api/src/routes/protected/user.test.ts @@ -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: [], diff --git a/api/src/routes/protected/user.ts b/api/src/routes/protected/user.ts index 4c8d6bc2e48..f90fe1a4904 100644 --- a/api/src/routes/protected/user.ts +++ b/api/src/routes/protected/user.ts @@ -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 } }, diff --git a/api/src/routes/public/user.test.ts b/api/src/routes/public/user.test.ts index f13fb9839c3..3df99a1f94e 100644 --- a/api/src/routes/public/user.test.ts +++ b/api/src/routes/public/user.test.ts @@ -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 } }; diff --git a/api/src/routes/public/user.ts b/api/src/routes/public/user.ts index b37d1d5a643..4e0782fac3b 100644 --- a/api/src/routes/public/user.ts +++ b/api/src/routes/public/user.ts @@ -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 = { diff --git a/api/src/schemas.ts b/api/src/schemas.ts index f895abba3ee..88335b2a29e 100644 --- a/api/src/schemas.ts +++ b/api/src/schemas.ts @@ -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'; diff --git a/api/src/schemas/settings/update-my-experience.ts b/api/src/schemas/settings/update-my-experience.ts new file mode 100644 index 00000000000..cd6727560cf --- /dev/null +++ b/api/src/schemas/settings/update-my-experience.ts @@ -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') + }) + } +}; diff --git a/api/src/schemas/settings/update-my-profile-ui.ts b/api/src/schemas/settings/update-my-profile-ui.ts index 76cee2581db..05e0deebeca 100644 --- a/api/src/schemas/settings/update-my-profile-ui.ts +++ b/api/src/schemas/settings/update-my-profile-ui.ts @@ -12,6 +12,7 @@ export const updateMyProfileUI = { showName: Type.Boolean(), showPoints: Type.Boolean(), showPortfolio: Type.Boolean(), + showExperience: Type.Boolean(), showTimeLine: Type.Boolean() }) }), diff --git a/api/src/schemas/types.ts b/api/src/schemas/types.ts index 48b4af03cb4..1acb3833ed0 100644 --- a/api/src/schemas/types.ts +++ b/api/src/schemas/types.ts @@ -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() }); diff --git a/api/src/schemas/user/get-session-user.ts b/api/src/schemas/user/get-session-user.ts index 87fd49fcda5..bca81419637 100644 --- a/api/src/schemas/user/get-session-user.ts +++ b/api/src/schemas/user/get-session-user.ts @@ -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(), diff --git a/api/src/utils/create-user.ts b/api/src/utils/create-user.ts index f3cda706ed2..130173f906a 100644 --- a/api/src/utils/create-user.ts +++ b/api/src/utils/create-user.ts @@ -87,6 +87,7 @@ export function createUserInput(email: string) { showAbout: false, showCerts: false, showDonation: false, + showExperience: false, showHeatMap: false, showLocation: false, showName: false, diff --git a/api/src/utils/normalize.test.ts b/api/src/utils/normalize.test.ts index c7fd9c88c8d..4ae8121303c 100644 --- a/api/src/utils/normalize.test.ts +++ b/api/src/utils/normalize.test.ts @@ -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 }); }); }); diff --git a/api/src/utils/normalize.ts b/api/src/utils/normalize.ts index c603764ee59..a5fbca5dacd 100644 --- a/api/src/utils/normalize.ts +++ b/api/src/utils/normalize.ts @@ -137,7 +137,8 @@ export const normalizeProfileUI = ( showName: false, showPoints: false, showPortfolio: false, - showTimeLine: false + showTimeLine: false, + showExperience: false }; }; diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index b689fca0d7d..c1a77c973ce 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -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", diff --git a/client/src/components/helpers/index.ts b/client/src/components/helpers/index.ts index 4df2d49bee2..cd62cb23496 100644 --- a/client/src/components/helpers/index.ts +++ b/client/src/components/helpers/index.ts @@ -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'; diff --git a/client/src/components/helpers/interleave.ts b/client/src/components/helpers/interleave.ts new file mode 100644 index 00000000000..5b97359eb82 --- /dev/null +++ b/client/src/components/helpers/interleave.ts @@ -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( + 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; +} diff --git a/client/src/components/profile/components/experience-display.css b/client/src/components/profile/components/experience-display.css new file mode 100644 index 00000000000..f6065a192b5 --- /dev/null +++ b/client/src/components/profile/components/experience-display.css @@ -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; +} diff --git a/client/src/components/profile/components/experience-display.tsx b/client/src/components/profile/components/experience-display.tsx new file mode 100644 index 00000000000..5be4a2a4081 --- /dev/null +++ b/client/src/components/profile/components/experience-display.tsx @@ -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 => ( +
+

{exp.title}

+

+ {exp.company} + {exp.location && ` • ${exp.location}`} +

+

+ {formatDate(exp.startDate)} + {' - '} + {exp.endDate + ? formatDate(exp.endDate) + : t('profile.experience.present')} +

+ {exp.description && ( +

{exp.description}

+ )} +
+ )); + + return ( + +
+

{t('profile.experience.heading')}

+ + {interleave(experienceItems, index => ( +
+ ))} + +
+
+ ); +}; diff --git a/client/src/components/profile/components/experience.test.tsx b/client/src/components/profile/components/experience.test.tsx new file mode 100644 index 00000000000..141936106f4 --- /dev/null +++ b/client/src/components/profile/components/experience.test.tsx @@ -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' + }); + }); +}); diff --git a/client/src/components/profile/components/experience.tsx b/client/src/components/profile/components/experience.tsx new file mode 100644 index 00000000000..c19105b06d3 --- /dev/null +++ b/client/src/components/profile/components/experience.tsx @@ -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(null); + + const createOnChangeHandler = + ( + id: string, + key: + | 'title' + | 'company' + | 'location' + | 'startDate' + | 'endDate' + | 'description' + ) => + (e: React.ChangeEvent) => { + 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, id: string) => { + e.preventDefault(); + if (isButtonDisabled) return null; + return saveItem(id); + }; + return ( + +
handleSubmit(e, id)}> + + + {t('profile.experience.job-title')}{' '} + + + + {titleMessage ? ( + {titleMessage} + ) : null} + + + + {t('profile.experience.company')}{' '} + + + + {companyMessage ? ( + {companyMessage} + ) : null} + + + + {t('profile.experience.location')} + + + + + + {t('profile.experience.start-date')}{' '} + + + + {startDateMessage ? ( + + {startDateMessage} + + ) : null} + + + + {t('profile.experience.end-date')} ( + {t('profile.experience.end-date-helper')}) + + + {endDateMessage ? ( + {endDateMessage} + ) : null} + + + + {t('profile.experience.description')}{' '} + + + + {descriptionMessage ? ( + + {descriptionMessage} + + ) : null} + + + {t('profile.experience.save')} + + + + +
+ ); + }; + + return ( +
+ {t('profile.experience.heading')} + +

{t('profile.experience.share-experience')}

+ + +
+ + {interleave(experience.map(renderExperience), () => ( + <> + +
+ + + ))} +
+ ); +}; + +ExperienceSettings.displayName = 'ExperienceSettings'; + +export default withTranslation()( + connect(null, mapDispatchToProps)(ExperienceSettings) +); diff --git a/client/src/components/profile/profile.tsx b/client/src/components/profile/profile.tsx index e33ada45006..bfab2265513 100644 --- a/client/src/components/profile/profile.tsx +++ b/client/src/components/profile/profile.tsx @@ -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) => { }; const EditModal = ({ user, isEditing, setIsEditing }: EditModalProps) => { - const { portfolio, username } = user; + const { portfolio, experience, username } = user; const { t } = useTranslation(); return ( setIsEditing(false)} open={isEditing} size='large'> @@ -60,6 +62,8 @@ const EditModal = ({ user, isEditing, setIsEditing }: EditModalProps) => { + + ); @@ -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 ? ( ) : null} + {showExperience ? ( + + ) : null} {showCerts ? : null} {showTimeLine ? ( diff --git a/client/src/components/settings/privacy.tsx b/client/src/components/settings/privacy.tsx index 251bd13c453..904420f1ac3 100644 --- a/client/src/components/settings/privacy.tsx +++ b/client/src/components/settings/privacy.tsx @@ -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')} /> + 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 }) => diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts index 299ffc30723..fc3ca4b261e 100644 --- a/client/src/redux/prop-types.ts +++ b/client/src/redux/prop-types.ts @@ -437,6 +437,7 @@ export type User = { picture: string; points: number; portfolio: PortfolioProjectData[]; + experience?: ExperienceData[]; profileUI: ProfileUI; progressTimestamps: Array; 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; diff --git a/client/src/redux/settings/action-types.js b/client/src/redux/settings/action-types.js index 8bb0ddde85b..bab4e535647 100644 --- a/client/src/redux/settings/action-types.js +++ b/client/src/redux/settings/action-types.js @@ -14,6 +14,7 @@ export const actionTypes = createTypes( ...createAsyncTypes('updateMyHonesty'), ...createAsyncTypes('updateMyQuincyEmail'), ...createAsyncTypes('updateMyPortfolio'), + ...createAsyncTypes('updateMyExperience'), ...createAsyncTypes('submitProfileUI'), ...createAsyncTypes('verifyCert'), ...createAsyncTypes('resetProgress'), diff --git a/client/src/redux/settings/actions.js b/client/src/redux/settings/actions.js index 4a561ff4650..fac529b414e 100644 --- a/client/src/redux/settings/actions.js +++ b/client/src/redux/settings/actions.js @@ -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 diff --git a/client/src/redux/settings/settings-sagas.js b/client/src/redux/settings/settings-sagas.js index 90efa9d86d6..c8d466c275f 100644 --- a/client/src/redux/settings/settings-sagas.js +++ b/client/src/redux/settings/settings-sagas.js @@ -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), diff --git a/client/src/utils/ajax.ts b/client/src/utils/ajax.ts index 57cb79e522c..9a1ea6e3366 100644 --- a/client/src/utils/ajax.ts +++ b/client/src/utils/ajax.ts @@ -409,6 +409,12 @@ export function putUpdateMyPortfolio( return put('/update-my-portfolio', update); } +export function putUpdateMyExperience( + update: Record +): Promise> { + return put('/update-my-experience', update); +} + export function putUserUpdateEmail( email: string ): Promise> { diff --git a/e2e/completed-project-preview.spec.ts b/e2e/completed-project-preview.spec.ts index 01949344478..7cb24eaf92d 100644 --- a/e2e/completed-project-preview.spec.ts +++ b/e2e/completed-project-preview.spec.ts @@ -16,6 +16,7 @@ const unlockedProfile = { showName: true, showPoints: true, showPortfolio: true, + showExperience: true, showTimeLine: true }; diff --git a/e2e/experience.spec.ts b/e2e/experience.spec.ts new file mode 100644 index 00000000000..1160c6e7a40 --- /dev/null +++ b/e2e/experience.spec.ts @@ -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/ + ); + }); +}); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index a256d131483..bea9d6e6e05 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -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 }); diff --git a/tools/scripts/seed/user-data.js b/tools/scripts/seed/user-data.js index c3bd4f397a6..b3793d7de30 100644 --- a/tools/scripts/seed/user-data.js +++ b/tools/scripts/seed/user-data.js @@ -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: {