diff --git a/client/src/client-only-routes/show-certification.tsx b/client/src/client-only-routes/show-certification.tsx index 5f8d6c9bd52..d2cd6c7dc91 100644 --- a/client/src/client-only-routes/show-certification.tsx +++ b/client/src/client-only-routes/show-certification.tsx @@ -21,10 +21,11 @@ import { showCertFetchStateSelector, userFetchStateSelector, isDonatingSelector, - userByNameSelector, - usernameSelector + usernameSelector, + createUserByNameSelector, + isSignedInSelector } from '../redux/selectors'; -import { UserFetchState, User } from '../redux/prop-types'; +import type { UserFetchState, User } from '../redux/prop-types'; import { liveCerts } from '../../config/cert-and-project-map'; import { certificateMissingErrorMessage, @@ -67,6 +68,7 @@ interface ShowCertificationProps { }; isDonating: boolean; isValidCert: boolean; + isSignedIn: boolean; location: { pathname: string; }; @@ -78,41 +80,48 @@ interface ShowCertificationProps { certSlug: string; }) => void; signedInUserName: string; - user: User; + user: User | null; userFetchState: UserFetchState; userFullName: string; username: string; } -const requestedUserSelector = (state: unknown, { username = '' }) => - userByNameSelector(username.toLowerCase())(state) as User; - const mapStateToProps = (state: unknown, props: ShowCertificationProps) => { const isValidCert = liveCerts.some( ({ certSlug }) => String(certSlug) === props.certSlug ); + + const { username } = props; + + const userByNameSelector = createUserByNameSelector(username) as ( + state: unknown + ) => User | null; + return createSelector( showCertSelector, showCertFetchStateSelector, usernameSelector, + userByNameSelector, userFetchStateSelector, isDonatingSelector, - requestedUserSelector, + isSignedInSelector, ( cert: Cert, fetchState: ShowCertificationProps['fetchState'], signedInUserName: string, + user: User | null, userFetchState: UserFetchState, isDonating: boolean, - user: User + isSignedIn: boolean ) => ({ cert, fetchState, isValidCert, signedInUserName, + user, userFetchState, isDonating, - user + isSignedIn }) ); }; @@ -293,24 +302,19 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => { userFetchState: { complete: userComplete }, signedInUserName, isDonating, + isSignedIn, cert: { username = '' }, fetchProfileForUser, user } = props; - if (!signedInUserName || signedInUserName !== username) { - if (isEmpty(user) && username) { - fetchProfileForUser(username); - } + const isSessionUser = isSignedIn && signedInUserName === username; + + if (isEmpty(user) && username) { + fetchProfileForUser(username); } - if ( - !isDonationDisplayed && - userComplete && - signedInUserName && - signedInUserName === username && - !isDonating - ) { + if (!isDonationDisplayed && userComplete && isSessionUser && !isDonating) { setIsDonationDisplayed(true); callGA({ event: 'donation_view', @@ -341,7 +345,8 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => { isValidCert, createFlashMessage, signedInUserName, - location: { pathname } + location: { pathname }, + user } = props; const { pending, complete, errored } = fetchState; @@ -359,7 +364,7 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => { return ; } - if (pending) { + if (pending || !user) { return ; } @@ -376,8 +381,6 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => { completionTime } = cert; - const { user } = props; - const displayName = userFullName ?? username; const certDate = new Date(date); diff --git a/client/src/client-only-routes/show-profile-or-four-oh-four.tsx b/client/src/client-only-routes/show-profile-or-four-oh-four.tsx index 278babafdde..528c7dab291 100644 --- a/client/src/client-only-routes/show-profile-or-four-oh-four.tsx +++ b/client/src/client-only-routes/show-profile-or-four-oh-four.tsx @@ -8,11 +8,11 @@ import Loader from '../components/helpers/loader'; import Profile from '../components/profile/profile'; import { fetchProfileForUser } from '../redux/actions'; import { - usernameSelector, - userByNameSelector, - userProfileFetchStateSelector + userSelector, + userProfileFetchStateSelector, + createUserByNameSelector } from '../redux/selectors'; -import { User } from '../redux/prop-types'; +import type { User } from '../redux/prop-types'; import { Socials } from '../components/profile/components/internet'; interface ShowProfileOrFourOhFourProps { @@ -20,38 +20,31 @@ interface ShowProfileOrFourOhFourProps { updateMyPortfolio: () => void; submitNewAbout: () => void; updateMySocials: (formValues: Socials) => void; - fetchState: { - pending: boolean; - complete: boolean; - errored: boolean; - }; isSessionUser: boolean; maybeUser?: string; - requestedUser: User; + requestedUser: User | null; showLoading: boolean; } -const createRequestedUserSelector = - () => - (state: unknown, { maybeUser = '' }) => - userByNameSelector(maybeUser.toLowerCase())(state) as User; -const createIsSessionUserSelector = - () => - (state: unknown, { maybeUser = '' }) => - maybeUser.toLowerCase() === usernameSelector(state); - const makeMapStateToProps = - () => (state: unknown, props: ShowProfileOrFourOhFourProps) => { - const requestedUserSelector = createRequestedUserSelector(); - const isSessionUserSelector = createIsSessionUserSelector(); - const fetchState = userProfileFetchStateSelector( - state - ) as ShowProfileOrFourOhFourProps['fetchState']; + () => + (state: unknown, { maybeUser = '' }) => { + const username = maybeUser.toLowerCase(); + const requestedUser = ( + createUserByNameSelector as ( + maybeUser: string + ) => (state: unknown) => User | null + )(username)(state); + const sessionUser = userSelector(state) as User | null; + const isSessionUser = username === sessionUser?.username; + const fetchState = userProfileFetchStateSelector(state) as { + pending: boolean; + }; + return { - requestedUser: requestedUserSelector(state, props), - isSessionUser: isSessionUserSelector(state, props), - showLoading: fetchState.pending, - fetchState + requestedUser, + isSessionUser, + showLoading: fetchState.pending }; }; @@ -70,10 +63,8 @@ function ShowProfileOrFourOhFour({ }: ShowProfileOrFourOhFourProps) { useEffect(() => { // If the user is not already in the store, fetch it - if (isEmpty(requestedUser)) { - if (maybeUser) { - fetchProfileForUser(maybeUser); - } + if (isEmpty(requestedUser) && maybeUser) { + fetchProfileForUser(maybeUser); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/client/src/client-only-routes/show-settings.tsx b/client/src/client-only-routes/show-settings.tsx index e18793edea6..92919a5364f 100644 --- a/client/src/client-only-routes/show-settings.tsx +++ b/client/src/client-only-routes/show-settings.tsx @@ -26,7 +26,7 @@ import { isSignedInSelector, userTokenSelector } from '../redux/selectors'; -import { User } from '../redux/prop-types'; +import type { User } from '../redux/prop-types'; import { submitNewAbout, updateMyHonesty, @@ -50,7 +50,7 @@ type ShowSettingsProps = { toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void; updateIsHonest: () => void; updateQuincyEmail: (isSendQuincyEmail: boolean) => void; - user: User; + user: User | null; verifyCert: typeof verifyCert; path?: string; userToken: string | null; @@ -61,7 +61,12 @@ const mapStateToProps = createSelector( userSelector, isSignedInSelector, userTokenSelector, - (showLoading: boolean, user: User, isSignedIn, userToken: string | null) => ({ + ( + showLoading: boolean, + user: User | null, + isSignedIn, + userToken: string | null + ) => ({ showLoading, user, isSignedIn, @@ -91,34 +96,7 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element { toggleSoundMode, toggleKeyboardShortcuts, resetEditorLayout, - user: { - completedChallenges, - email, - is2018DataVisCert, - isApisMicroservicesCert, - isJsAlgoDataStructCert, - isBackEndCert, - isDataVisCert, - isFrontEndCert, - isInfosecQaCert, - isQaCertV7, - isInfosecCertV7, - isFrontEndLibsCert, - isFullStackCert, - isRespWebDesignCert, - isSciCompPyCertV7, - isDataAnalysisPyCertV7, - isMachineLearningPyCertV7, - isRelationalDatabaseCertV8, - isCollegeAlgebraPyCertV8, - isFoundationalCSharpCertV8, - isJsAlgoDataStructCertV8, - isEmailVerified, - isHonest, - sendQuincyEmail, - username, - keyboardShortcuts - }, + user, navigate, showLoading, updateQuincyEmail, @@ -126,11 +104,12 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element { verifyCert, userToken } = props; + const isSignedInRef = useRef(isSignedIn); const examTokenFlag = useFeatureIsOn('exam-token-widget'); - if (showLoading) { + if (showLoading || !user) { return ; } @@ -138,6 +117,36 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element { navigate(`${apiLocation}/signin`); return ; } + + const { + completedChallenges, + email, + is2018DataVisCert, + isApisMicroservicesCert, + isJsAlgoDataStructCert, + isBackEndCert, + isDataVisCert, + isFrontEndCert, + isInfosecQaCert, + isQaCertV7, + isInfosecCertV7, + isFrontEndLibsCert, + isFullStackCert, + isRespWebDesignCert, + isSciCompPyCertV7, + isDataAnalysisPyCertV7, + isMachineLearningPyCertV7, + isRelationalDatabaseCertV8, + isCollegeAlgebraPyCertV8, + isFoundationalCSharpCertV8, + isJsAlgoDataStructCertV8, + isEmailVerified, + isHonest, + sendQuincyEmail, + username, + keyboardShortcuts + } = user; + const sound = (store.get('fcc-sound') as boolean) ?? false; const editorLayout = (store.get('challenge-layout') as boolean) ?? false; return ( diff --git a/client/src/client-only-routes/show-update-email.tsx b/client/src/client-only-routes/show-update-email.tsx index 3417fe7aaba..bbb58f7650e 100644 --- a/client/src/client-only-routes/show-update-email.tsx +++ b/client/src/client-only-routes/show-update-email.tsx @@ -27,6 +27,7 @@ import { isSignedInSelector, userSelector } from '../redux/selectors'; import { hardGoTo as navigate } from '../redux/actions'; import { updateMyEmail } from '../redux/settings/actions'; import { maybeEmailRE } from '../utils'; +import type { User } from '../redux/prop-types'; const { apiLocation } = envData; @@ -41,11 +42,8 @@ interface ShowUpdateEmailProps { const mapStateToProps = createSelector( userSelector, isSignedInSelector, - ( - { email, emailVerified }: { email: string; emailVerified: boolean }, - isSignedIn - ) => ({ - isNewEmail: !email || emailVerified, + (user: User | null, isSignedIn) => ({ + isNewEmail: !user?.email || user.emailVerified, isSignedIn }) ); diff --git a/client/src/components/Donation/donate-form.tsx b/client/src/components/Donation/donate-form.tsx index 4e63fc7a704..77452921d3e 100644 --- a/client/src/components/Donation/donate-form.tsx +++ b/client/src/components/Donation/donate-form.tsx @@ -25,7 +25,7 @@ import { themeSelector } from '../../redux/selectors'; import { LocalStorageThemes, DonateFormState } from '../../redux/types'; -import type { CompletedChallenge } from '../../redux/prop-types'; +import type { CompletedChallenge, User } from '../../redux/prop-types'; import { CENTS_IN_DOLLAR, formattedAmountLabel } from './utils'; import DonateCompletion from './donate-completion'; import PatreonButton from './patreon-button'; @@ -62,7 +62,7 @@ type PostCharge = (data: { type DonateFormProps = { postCharge: PostCharge; defaultTheme?: LocalStorageThemes; - email: string; + email?: string; handleProcessing?: () => void; editAmount?: () => void; selectedDonationAmount?: DonationAmount; @@ -91,7 +91,7 @@ const mapStateToProps = createSelector( isSignedIn: DonateFormProps['isSignedIn'], isDonating: DonateFormProps['isDonating'], donationFormState: DonateFormState, - { email }: { email: string }, + user: User | null, completedChallenges: CompletedChallenge[], theme: LocalStorageThemes ) => ({ @@ -99,7 +99,7 @@ const mapStateToProps = createSelector( isDonating, showLoading, donationFormState, - email, + email: user?.email, completedChallenges, theme }) diff --git a/client/src/components/Donation/paypal-button.tsx b/client/src/components/Donation/paypal-button.tsx index 8feaffbfa0c..8b876d092e5 100644 --- a/client/src/components/Donation/paypal-button.tsx +++ b/client/src/components/Donation/paypal-button.tsx @@ -13,6 +13,7 @@ import { import envData from '../../../config/env.json'; import { userSelector, signInLoadingSelector } from '../../redux/selectors'; import { LocalStorageThemes } from '../../redux/types'; +import type { User } from '../../redux/prop-types'; import { DonationApprovalData, PostPayment } from './types'; import PayPalButtonScriptLoader from './paypal-button-script-loader'; @@ -177,8 +178,8 @@ class PaypalButton extends Component { const mapStateToProps = createSelector( userSelector, signInLoadingSelector, - ({ isDonating }: { isDonating: boolean }, showLoading: boolean) => ({ - isDonating, + (user: User | null, showLoading: boolean) => ({ + isDonating: !!user?.isDonating, showLoading }) ); diff --git a/client/src/components/Intro/index.tsx b/client/src/components/Intro/index.tsx index 7b5758186e8..6b850240a7e 100644 --- a/client/src/components/Intro/index.tsx +++ b/client/src/components/Intro/index.tsx @@ -10,7 +10,7 @@ import LearnAlert from './learn-alert'; interface IntroProps { complete?: boolean; - completedChallengeCount?: number; + completedChallengeCount: number; isSignedIn?: boolean; name?: string; pending?: boolean; diff --git a/client/src/components/Intro/intro.test.tsx b/client/src/components/Intro/intro.test.tsx index e613503a993..702a5c0e50f 100644 --- a/client/src/components/Intro/intro.test.tsx +++ b/client/src/components/Intro/intro.test.tsx @@ -27,6 +27,7 @@ describe('', () => { const loggedInProps = { complete: true, + completedChallengeCount: 0, isSignedIn: true, name: 'Development User', navigate: () => jest.fn(), @@ -39,6 +40,7 @@ const loggedInProps = { const loggedOutProps = { complete: true, + completedChallengeCount: 0, isSignedIn: false, name: '', navigate: () => jest.fn(), diff --git a/client/src/components/Map/index.tsx b/client/src/components/Map/index.tsx index ea0ccee64cc..729af347432 100644 --- a/client/src/components/Map/index.tsx +++ b/client/src/components/Map/index.tsx @@ -1,8 +1,6 @@ import i18next from 'i18next'; -import { connect } from 'react-redux'; import React, { Fragment } from 'react'; import { Spacer } from '@freecodecamp/ui'; -import { createSelector } from 'reselect'; import { useTranslation } from 'react-i18next'; import { @@ -17,13 +15,6 @@ import { ButtonLink } from '../helpers'; import { showUpcomingChanges } from '../../../config/env.json'; import './map.css'; - -import { - isSignedInSelector, - currentCertsSelector, - completedChallengesIdsSelector -} from '../../redux/selectors'; - interface MapProps { forLanding?: boolean; } @@ -46,17 +37,6 @@ const superBlockHeadings: { [key in SuperBlockStage]: string } = { [SuperBlockStage.Catalog]: 'landing.catalog-heading' }; -const mapStateToProps = createSelector( - isSignedInSelector, - currentCertsSelector, - completedChallengesIdsSelector, - (isSignedIn: boolean, currentCerts, completedChallengeIds: string[]) => ({ - isSignedIn, - currentCerts, - completedChallengeIds - }) -); - function MapLi({ superBlock, landing = false @@ -124,4 +104,4 @@ function Map({ forLanding = false }: MapProps) { Map.displayName = 'Map'; -export default connect(mapStateToProps)(Map); +export default Map; diff --git a/client/src/components/growth-book/growth-book-wrapper.tsx b/client/src/components/growth-book/growth-book-wrapper.tsx index 2d1f94410dd..6fb7c393931 100644 --- a/client/src/components/growth-book/growth-book-wrapper.tsx +++ b/client/src/components/growth-book/growth-book-wrapper.tsx @@ -6,14 +6,10 @@ import { } from '@growthbook/growthbook-react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { - isSignedInSelector, - userSelector, - userFetchStateSelector -} from '../../redux/selectors'; +import { userSelector, userFetchStateSelector } from '../../redux/selectors'; import envData from '../../../config/env.json'; import defaultGrowthBookFeatures from '../../../config/growthbook-features-default.json'; -import { User, UserFetchState } from '../../redux/prop-types'; +import type { User, UserFetchState } from '../../redux/prop-types'; import { getUUID } from '../../utils/growthbook-cookie'; import callGA from '../../analytics/call-ga'; import GrowthBookReduxConnector from './growth-book-redux-connector'; @@ -30,11 +26,9 @@ declare global { } const mapStateToProps = createSelector( - isSignedInSelector, userSelector, userFetchStateSelector, - (isSignedIn, user: User, userFetchState: UserFetchState) => ({ - isSignedIn, + (user: User | null, userFetchState: UserFetchState) => ({ user, userFetchState }) @@ -56,7 +50,6 @@ interface UserAttributes { const GrowthBookWrapper = ({ children, - isSignedIn, user, userFetchState }: GrowthBookWrapper) => { @@ -105,7 +98,7 @@ const GrowthBookWrapper = ({ clientLocal: clientLocale }; - if (isSignedIn) { + if (user) { userAttributes = { ...userAttributes, staff: user.email.includes('@freecodecamp'), @@ -116,7 +109,7 @@ const GrowthBookWrapper = ({ } growthbook.setAttributes(userAttributes); } - }, [isSignedIn, user, userFetchState, growthbook]); + }, [user, userFetchState, growthbook]); return ( diff --git a/client/src/components/profile/components/certifications.tsx b/client/src/components/profile/components/certifications.tsx index b0284098e28..8fce62fb974 100644 --- a/client/src/components/profile/components/certifications.tsx +++ b/client/src/components/profile/components/certifications.tsx @@ -1,42 +1,15 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; import { Spacer } from '@freecodecamp/ui'; -import { certificatesByNameSelector } from '../../../redux/selectors'; -import type { CurrentCert } from '../../../redux/prop-types'; +import type { CurrentCert, User } from '../../../redux/prop-types'; import { FullWidthRow, ButtonLink } from '../../helpers'; +import { getCertifications } from './utils/certification'; + import './certifications.css'; -const mapStateToProps = ( - state: Record, - props: CertificationProps -) => - createSelector( - certificatesByNameSelector(props.username.toLowerCase()), - ({ - hasModernCert, - hasLegacyCert, - currentCerts, - legacyCerts - }: Pick< - CertificationProps, - 'hasModernCert' | 'hasLegacyCert' | 'currentCerts' | 'legacyCerts' - >) => ({ - hasModernCert, - hasLegacyCert, - currentCerts, - legacyCerts - }) - )(state); - interface CertificationProps { - currentCerts?: CurrentCert[]; - hasLegacyCert?: boolean; - hasModernCert?: boolean; - legacyCerts?: CurrentCert[]; - username: string; + user: User; } interface CertButtonProps { @@ -62,13 +35,12 @@ function CertButton({ username, cert }: CertButtonProps): JSX.Element { ); } -function Certificates({ - currentCerts, - legacyCerts, - hasLegacyCert, - hasModernCert, - username -}: CertificationProps): JSX.Element { +function Certificates({ user }: CertificationProps): JSX.Element { + const { username } = user; + + const { currentCerts, legacyCerts, hasLegacyCert, hasModernCert } = + getCertifications(user); + const { t } = useTranslation(); return ( @@ -122,4 +94,4 @@ function Certificates({ Certificates.displayName = 'Certifications'; -export default connect(mapStateToProps)(Certificates); +export default Certificates; diff --git a/client/src/components/profile/components/utils/certification.ts b/client/src/components/profile/components/utils/certification.ts new file mode 100644 index 00000000000..d0857cd1c02 --- /dev/null +++ b/client/src/components/profile/components/utils/certification.ts @@ -0,0 +1,152 @@ +import { Certification } from '../../../../../../shared/config/certification-settings'; +import { User } from '../../../../redux/prop-types'; + +export const getCertifications = (user: User) => { + const { + isRespWebDesignCert, + is2018DataVisCert, + isFrontEndLibsCert, + isJsAlgoDataStructCert, + isApisMicroservicesCert, + isInfosecQaCert, + isQaCertV7, + isInfosecCertV7, + isFrontEndCert, + isBackEndCert, + isDataVisCert, + isFullStackCert, + isSciCompPyCertV7, + isDataAnalysisPyCertV7, + isMachineLearningPyCertV7, + isRelationalDatabaseCertV8, + isCollegeAlgebraPyCertV8, + isFoundationalCSharpCertV8, + isJsAlgoDataStructCertV8 + } = user; + + return { + hasModernCert: + isRespWebDesignCert || + is2018DataVisCert || + isFrontEndLibsCert || + isApisMicroservicesCert || + isQaCertV7 || + isInfosecCertV7 || + isFullStackCert || + isSciCompPyCertV7 || + isDataAnalysisPyCertV7 || + isMachineLearningPyCertV7 || + isRelationalDatabaseCertV8 || + isCollegeAlgebraPyCertV8 || + isFoundationalCSharpCertV8 || + isJsAlgoDataStructCertV8, + hasLegacyCert: + isFrontEndCert || + isJsAlgoDataStructCert || + isBackEndCert || + isDataVisCert || + isInfosecQaCert, + isFullStackCert, + currentCerts: [ + { + show: isRespWebDesignCert, + title: 'Responsive Web Design Certification', + certSlug: Certification.RespWebDesign + }, + { + show: isJsAlgoDataStructCertV8, + title: 'JavaScript Algorithms and Data Structures Certification', + certSlug: Certification.JsAlgoDataStructNew + }, + { + show: isFrontEndLibsCert, + title: 'Front End Development Libraries Certification', + certSlug: Certification.FrontEndDevLibs + }, + { + show: is2018DataVisCert, + title: 'Data Visualization Certification', + certSlug: Certification.DataVis + }, + { + show: isRelationalDatabaseCertV8, + title: 'Relational Database Certification', + certSlug: Certification.RelationalDb + }, + { + show: isApisMicroservicesCert, + title: 'Back End Development and APIs Certification', + certSlug: Certification.BackEndDevApis + }, + { + show: isQaCertV7, + title: 'Quality Assurance Certification', + certSlug: Certification.QualityAssurance + }, + { + show: isSciCompPyCertV7, + title: 'Scientific Computing with Python Certification', + certSlug: Certification.SciCompPy + }, + { + show: isDataAnalysisPyCertV7, + title: 'Data Analysis with Python Certification', + certSlug: Certification.DataAnalysisPy + }, + { + show: isInfosecCertV7, + title: 'Information Security Certification', + certSlug: Certification.InfoSec + }, + { + show: isMachineLearningPyCertV7, + title: 'Machine Learning with Python Certification', + certSlug: Certification.MachineLearningPy + }, + { + show: isCollegeAlgebraPyCertV8, + title: 'College Algebra with Python Certification', + certSlug: Certification.CollegeAlgebraPy + }, + { + show: isFoundationalCSharpCertV8, + title: 'Foundational C# with Microsoft Certification', + certSlug: Certification.FoundationalCSharp + } + ], + legacyCerts: [ + { + show: isFrontEndCert, + title: 'Front End Certification', + certSlug: Certification.LegacyFrontEnd + }, + { + show: isJsAlgoDataStructCert, + title: 'Legacy JavaScript Algorithms and Data Structures Certification', + certSlug: Certification.JsAlgoDataStruct + }, + { + show: isBackEndCert, + title: 'Back End Certification', + certSlug: Certification.LegacyBackEnd + }, + { + show: isDataVisCert, + title: 'Data Visualization Certification', + certSlug: Certification.LegacyDataVis + }, + { + show: isInfosecQaCert, + title: 'Information Security and Quality Assurance Certification', + // Keep the current public profile cert slug + certSlug: Certification.LegacyInfoSecQa + }, + { + show: isFullStackCert, + title: 'Full Stack Certification', + // Keep the current public profile cert slug + certSlug: Certification.LegacyFullStack + } + ] + }; +}; diff --git a/client/src/components/profile/profile.test.tsx b/client/src/components/profile/profile.test.tsx index e7b63299870..55167362125 100644 --- a/client/src/components/profile/profile.test.tsx +++ b/client/src/components/profile/profile.test.tsx @@ -81,7 +81,7 @@ const notMyProfileProps = { }; function reducer() { return { - app: { appUsername: 'vasili', user: { vasili: userProps.user } } + app: { user: { sessionUser: userProps.user } } }; } function renderWithRedux(ui: JSX.Element) { diff --git a/client/src/components/profile/profile.tsx b/client/src/components/profile/profile.tsx index 73d0ab97d1c..6d492f4229e 100644 --- a/client/src/components/profile/profile.tsx +++ b/client/src/components/profile/profile.tsx @@ -122,7 +122,7 @@ function UserProfile({ user, isSessionUser }: ProfileProps): JSX.Element { {showPortfolio ? ( ) : null} - {showCerts ? : null} + {showCerts ? : null} {showTimeLine ? ( ) : null} diff --git a/client/src/pages/email-sign-up.tsx b/client/src/pages/email-sign-up.tsx index 8f9cf350de6..c5774ff76fe 100644 --- a/client/src/pages/email-sign-up.tsx +++ b/client/src/pages/email-sign-up.tsx @@ -17,6 +17,7 @@ import { userSelector, isSignedInSelector } from '../redux/selectors'; +import type { User } from '../redux/prop-types'; import './email-sign-up.css'; interface AcceptPrivacyTermsProps { @@ -24,25 +25,18 @@ interface AcceptPrivacyTermsProps { acceptedPrivacyTerms: boolean; isSignedIn: boolean; showLoading: boolean; - completedChallengeCount?: number; + completedChallengeCount: number; } const mapStateToProps = createSelector( userSelector, isSignedInSelector, signInLoadingSelector, - ( - { - acceptedPrivacyTerms, - completedChallengeCount - }: { acceptedPrivacyTerms: boolean; completedChallengeCount: number }, - isSignedIn: boolean, - showLoading: boolean - ) => ({ - acceptedPrivacyTerms, + (user: User | null, isSignedIn: boolean, showLoading: boolean) => ({ + acceptedPrivacyTerms: !!user?.acceptedPrivacyTerms, isSignedIn, showLoading, - completedChallengeCount + completedChallengeCount: user?.completedChallengeCount ?? 0 }) ); const mapDispatchToProps = (dispatch: Dispatch) => @@ -109,7 +103,7 @@ function AcceptPrivacyTerms({ acceptedPrivacyTerms, isSignedIn, showLoading, - completedChallengeCount = 0 + completedChallengeCount }: AcceptPrivacyTermsProps) { const { t } = useTranslation(); const acceptedPrivacyRef = useRef(acceptedPrivacyTerms); diff --git a/client/src/pages/learn.tsx b/client/src/pages/learn.tsx index 880059f0cb4..fdc1af4ad97 100644 --- a/client/src/pages/learn.tsx +++ b/client/src/pages/learn.tsx @@ -23,18 +23,18 @@ interface FetchState { errored: boolean; } -interface User { +type MaybeUser = { name: string; username: string; completedChallengeCount: number; isDonating: boolean; -} +} | null; const mapStateToProps = createSelector( userFetchStateSelector, isSignedInSelector, userSelector, - (fetchState: FetchState, isSignedIn: boolean, user: User) => ({ + (fetchState: FetchState, isSignedIn: boolean, user: MaybeUser) => ({ fetchState, isSignedIn, user @@ -49,7 +49,7 @@ interface LearnPageProps { isSignedIn: boolean; fetchState: FetchState; state: Record; - user: User; + user: MaybeUser; data: { challengeNode: { challenge: { @@ -59,10 +59,12 @@ interface LearnPageProps { }; } +const EMPTY_USER = { name: '', completedChallengeCount: 0, isDonating: false }; + function LearnPage({ isSignedIn, fetchState: { pending, complete }, - user: { name = '', completedChallengeCount = 0, isDonating = false }, + user, data: { challengeNode: { challenge: { @@ -71,6 +73,8 @@ function LearnPage({ } } }: LearnPageProps) { + const { name, completedChallengeCount, isDonating } = user ?? EMPTY_USER; + const { t } = useTranslation(); const onLearnDonationAlertClick = () => { diff --git a/client/src/redux/donation-saga.test.js b/client/src/redux/donation-saga.test.js index 3bb437107e1..cea7966eae4 100644 --- a/client/src/redux/donation-saga.test.js +++ b/client/src/redux/donation-saga.test.js @@ -49,9 +49,8 @@ const analyticsDataMock = { const signedInStoreMock = { app: { - appUsername: 'devuser', user: { - devuser: { + sessionUser: { completedChallenges: [ { id: 'bd7123c8c441eddfaeb5bdef', @@ -81,11 +80,8 @@ const signedInStoreMock = { const signedOutStoreMock = { app: { - appUsername: '', user: { - '': { - completedChallenges: [] - } + sessionUser: null } } }; @@ -162,11 +158,8 @@ describe('donation-saga', () => { const signedOutStoreMock = { app: { - appUsername: '', user: { - '': { - completedChallenges: [] - } + sessionUser: null } } }; diff --git a/client/src/redux/failed-updates-epic.test.js b/client/src/redux/failed-updates-epic.test.js index f677d5f290f..d3e3bc3bd26 100644 --- a/client/src/redux/failed-updates-epic.test.js +++ b/client/src/redux/failed-updates-epic.test.js @@ -28,7 +28,7 @@ const initialState = { app: { isOnline: true, isServerOnline: true, - appUsername: 'developmentuser' + user: { sessionUser: {} } } }; diff --git a/client/src/redux/fetch-user-saga.js b/client/src/redux/fetch-user-saga.js index 8f953855c25..6a2d8835be6 100644 --- a/client/src/redux/fetch-user-saga.js +++ b/client/src/redux/fetch-user-saga.js @@ -9,12 +9,9 @@ import { function* fetchSessionUser() { try { - const { - data: { user = {}, result = '' } - } = yield call(getSessionUser); - const appUser = user[result] || {}; + const { data: user } = yield call(getSessionUser); - yield put(fetchUserComplete({ user: appUser, username: result })); + yield put(fetchUserComplete({ user })); } catch (e) { console.log('failed to fetch user', e); yield put(fetchUserError(e)); @@ -25,13 +22,8 @@ function* fetchOtherUser({ payload: maybeUser = '' }) { try { const maybeUserLC = maybeUser.toLowerCase(); - const { - data: { entities: { user = {} } = {}, result = '' } - } = yield call(getUserProfile, maybeUserLC); - const otherUser = user[result] || {}; - yield put( - fetchProfileForUserComplete({ user: otherUser, username: result }) - ); + const { data: otherUser } = yield call(getUserProfile, maybeUserLC); + yield put(fetchProfileForUserComplete({ user: otherUser })); } catch (e) { yield put(fetchProfileForUserError(e)); } diff --git a/client/src/redux/index.js b/client/src/redux/index.js index 532d05435a0..24fa99db37b 100644 --- a/client/src/redux/index.js +++ b/client/src/redux/index.js @@ -50,7 +50,6 @@ export const defaultDonationFormState = { }; const initialState = { - appUsername: '', isRandomCompletionThreshold: false, donatableSectionRecentlyCompleted: null, currentChallengeId: store.get(CURRENT_CHALLENGE_KEY), @@ -61,7 +60,7 @@ const initialState = { showCertFetchState: { ...defaultFetchState }, - user: {}, + user: { sessionUser: null, otherUser: null }, userFetchState: { ...defaultFetchState }, @@ -107,8 +106,8 @@ function spreadThePayloadOnUser(state, payload) { ...state, user: { ...state.user, - [state.appUsername]: { - ...state.user[state.appUsername], + sessionUser: { + ...state.user.sessionUser, ...payload } } @@ -118,13 +117,12 @@ function spreadThePayloadOnUser(state, payload) { export const reducer = handleActions( { [actionTypes.acceptTermsComplete]: (state, { payload }) => { - const { appUsername } = state; return { ...state, user: { ...state.user, - [appUsername]: { - ...state.user[appUsername], + sessionUser: { + ...state.user.sessionUser, // TODO: the user accepts the privacy terms in practice during auth // however, it's currently being used to track if they've accepted // or rejected the newsletter. Ideally this should be migrated, @@ -132,7 +130,7 @@ export const reducer = handleActions( acceptedPrivacyTerms: true, sendQuincyEmail: payload === null - ? state.user[appUsername].sendQuincyEmail + ? state.user.sessionUser.sendQuincyEmail : payload } } @@ -175,13 +173,12 @@ export const reducer = handleActions( donationFormState: { ...defaultDonationFormState, processing: true } }), [actionTypes.postChargeComplete]: state => { - const { appUsername } = state; return { ...state, user: { ...state.user, - [appUsername]: { - ...state.user[appUsername], + sessionUser: { + ...state.user.sessionUser, isDonating: true } }, @@ -205,18 +202,14 @@ export const reducer = handleActions( ...state, userProfileFetchState: { ...defaultFetchState } }), - [actionTypes.fetchUserComplete]: ( - state, - { payload: { user, username } } - ) => ({ + [actionTypes.fetchUserComplete]: (state, { payload: { user } }) => ({ ...state, user: { ...state.user, - [username]: { ...user, sessionUser: true } + sessionUser: user }, - appUsername: username, currentChallengeId: - user.currentChallengeId || store.get(CURRENT_CHALLENGE_KEY), + user?.currentChallengeId || store.get(CURRENT_CHALLENGE_KEY), userFetchState: { pending: false, complete: true, @@ -235,15 +228,13 @@ export const reducer = handleActions( }), [actionTypes.fetchProfileForUserComplete]: ( state, - { payload: { user, username } } + { payload: { user } } ) => { - const previousUserObject = - username in state.user ? state.user[username] : {}; return { ...state, user: { ...state.user, - [username]: { ...previousUserObject, ...user } + otherUser: user }, userProfileFetchState: { ...defaultFetchState, @@ -291,8 +282,7 @@ export const reducer = handleActions( }), [actionTypes.resetUserData]: state => ({ ...state, - appUsername: '', - user: {} + user: { ...state.user, sessionUser: null } }), [actionTypes.openSignoutModal]: state => ({ ...state, @@ -335,15 +325,14 @@ export const reducer = handleActions( let submittedchallenges = [ { ...submittedChallenge, completedDate: Date.now() } ]; - const { appUsername } = state; return examResults && !examResults.passed ? { ...state, user: { ...state.user, - [appUsername]: { - ...state.user[appUsername], + sessionUser: { + ...state.user.sessionUser, examResults } } @@ -352,12 +341,12 @@ export const reducer = handleActions( ...state, user: { ...state.user, - [appUsername]: { - ...state.user[appUsername], + sessionUser: { + ...state.user.sessionUser, completedChallenges: uniqBy( [ ...submittedchallenges, - ...state.user[appUsername].completedChallenges + ...state.user.sessionUser.completedChallenges ], 'id' ), @@ -369,13 +358,12 @@ export const reducer = handleActions( }; }, [actionTypes.setMsUsername]: (state, { payload }) => { - const { appUsername } = state; return { ...state, user: { ...state.user, - [appUsername]: { - ...state.user[appUsername], + sessionUser: { + ...state.user.sessionUser, msUsername: payload } } @@ -388,26 +376,24 @@ export const reducer = handleActions( }; }, [actionTypes.updateUserToken]: (state, { payload }) => { - const { appUsername } = state; return { ...state, user: { ...state.user, - [appUsername]: { - ...state.user[appUsername], + sessionUser: { + ...state.user.sessionUser, userToken: payload } } }; }, [actionTypes.deleteUserTokenComplete]: state => { - const { appUsername } = state; return { ...state, user: { ...state.user, - [appUsername]: { - ...state.user[appUsername], + sessionUser: { + ...state.user.sessionUser, userToken: null } } @@ -426,13 +412,12 @@ export const reducer = handleActions( }; }, [actionTypes.clearExamResults]: state => { - const { appUsername } = state; return { ...state, user: { ...state.user, - [appUsername]: { - ...state.user[appUsername], + sessionUser: { + ...state.user.sessionUser, examResults: null } } @@ -442,14 +427,13 @@ export const reducer = handleActions( state, { payload: { surveyResults } } ) => { - const { appUsername } = state; - const { completedSurveys = [] } = state.user[appUsername]; + const { completedSurveys = [] } = state.user.sessionUser; return { ...state, user: { ...state.user, - [appUsername]: { - ...state.user[appUsername], + sessionUser: { + ...state.user.sessionUser, completedSurveys: [...completedSurveys, surveyResults] } } @@ -460,13 +444,12 @@ export const reducer = handleActions( currentChallengeId: payload }), [actionTypes.saveChallengeComplete]: (state, { payload }) => { - const { appUsername } = state; return { ...state, user: { ...state.user, - [appUsername]: { - ...state.user[appUsername], + sessionUser: { + ...state.user.sessionUser, savedChallenges: payload } } @@ -478,8 +461,8 @@ export const reducer = handleActions( ...state, user: { ...state.user, - [state.appUsername]: { - ...state.user[state.appUsername], + sessionUser: { + ...state.user.sessionUser, username: payload } } @@ -511,8 +494,8 @@ export const reducer = handleActions( ...state, user: { ...state.user, - [state.appUsername]: { - ...state.user[state.appUsername], + sessionUser: { + ...state.user.sessionUser, profileUI: { ...payload } } } diff --git a/client/src/redux/prop-types.ts b/client/src/redux/prop-types.ts index 7787c2d3e11..f0b5f14ac76 100644 --- a/client/src/redux/prop-types.ts +++ b/client/src/redux/prop-types.ts @@ -308,6 +308,7 @@ export type User = { about: string; acceptedPrivacyTerms: boolean; completedChallenges: CompletedChallenge[]; + completedChallengeCount: number; completedSurveys: SurveyResults[]; currentChallengeId: string; email: string; diff --git a/client/src/redux/selectors.js b/client/src/redux/selectors.js index 891df536410..e80a857a250 100644 --- a/client/src/redux/selectors.js +++ b/client/src/redux/selectors.js @@ -1,26 +1,25 @@ import { createSelector } from 'reselect'; -import { Certification } from '../../../shared/config/certification-settings'; import superBlockStructure from '../../../curriculum/superblock-structure/full-stack.json'; import { randomBetween } from '../utils/random-between'; import { getSessionChallengeData } from '../utils/session-storage'; import { ns as MainApp } from './action-types'; export const savedChallengesSelector = state => - userSelector(state).savedChallenges || []; + userSelector(state)?.savedChallenges || []; export const completedChallengesSelector = state => - userSelector(state).completedChallenges || []; -export const userIdSelector = state => userSelector(state).id; + userSelector(state)?.completedChallenges || []; +export const userIdSelector = state => userSelector(state)?.id; export const partiallyCompletedChallengesSelector = state => - userSelector(state).partiallyCompletedChallenges || []; + userSelector(state)?.partiallyCompletedChallenges || []; export const currentChallengeIdSelector = state => state[MainApp].currentChallengeId; export const isRandomCompletionThresholdSelector = state => state[MainApp].isRandomCompletionThreshold; -export const isDonatingSelector = state => userSelector(state).isDonating; +export const isDonatingSelector = state => userSelector(state)?.isDonating; export const isOnlineSelector = state => state[MainApp].isOnline; export const isServerOnlineSelector = state => state[MainApp].isServerOnline; -export const isSignedInSelector = state => !!state[MainApp].appUsername; +export const isSignedInSelector = state => !!userSelector(state); export const isDonationModalOpenSelector = state => state[MainApp].showDonationModal; export const isSignoutModalOpenSelector = state => @@ -88,185 +87,28 @@ export const shouldRequestDonationSelector = state => { } }; -export const userTokenSelector = state => { - return userSelector(state).userToken; -}; +export const userTokenSelector = state => userSelector(state)?.userToken; -export const examInProgressSelector = state => { - return state[MainApp].examInProgress; -}; +export const examInProgressSelector = state => state[MainApp].examInProgress; -export const examResultsSelector = state => userSelector(state).examResults; +export const examResultsSelector = state => userSelector(state)?.examResults; -export const msUsernameSelector = state => { - return userSelector(state).msUsername; -}; +export const msUsernameSelector = state => userSelector(state)?.msUsername; export const completedSurveysSelector = state => - userSelector(state).completedSurveys || []; + userSelector(state)?.completedSurveys || []; export const isProcessingSelector = state => { return state[MainApp].isProcessing; }; -export const userByNameSelector = username => state => { - const { user } = state[MainApp]; - // return initial state empty user empty object instead of empty - // object literal to prevent components from re-rendering unnecessarily - // TODO: confirm if "initialState" can be moved here or action-types.js - return user[username] ?? {}; -}; - -export const currentCertsSelector = state => - certificatesByNameSelector(state[MainApp]?.appUsername)(state)?.currentCerts; - -export const certificatesByNameSelector = username => state => { - const { - isRespWebDesignCert, - is2018DataVisCert, - isFrontEndLibsCert, - isJsAlgoDataStructCert, - isApisMicroservicesCert, - isInfosecQaCert, - isQaCertV7, - isInfosecCertV7, - isFrontEndCert, - isBackEndCert, - isDataVisCert, - isFullStackCert, - isSciCompPyCertV7, - isDataAnalysisPyCertV7, - isMachineLearningPyCertV7, - isRelationalDatabaseCertV8, - isCollegeAlgebraPyCertV8, - isFoundationalCSharpCertV8, - isJsAlgoDataStructCertV8 - } = userByNameSelector(username)(state); - return { - hasModernCert: - isRespWebDesignCert || - is2018DataVisCert || - isFrontEndLibsCert || - isApisMicroservicesCert || - isQaCertV7 || - isInfosecCertV7 || - isFullStackCert || - isSciCompPyCertV7 || - isDataAnalysisPyCertV7 || - isMachineLearningPyCertV7 || - isRelationalDatabaseCertV8 || - isCollegeAlgebraPyCertV8 || - isFoundationalCSharpCertV8 || - isJsAlgoDataStructCertV8, - hasLegacyCert: - isFrontEndCert || - isJsAlgoDataStructCert || - isBackEndCert || - isDataVisCert || - isInfosecQaCert, - isFullStackCert, - currentCerts: [ - { - show: isRespWebDesignCert, - title: 'Responsive Web Design Certification', - certSlug: Certification.RespWebDesign - }, - { - show: isJsAlgoDataStructCertV8, - title: 'JavaScript Algorithms and Data Structures Certification', - certSlug: Certification.JsAlgoDataStructNew - }, - { - show: isFrontEndLibsCert, - title: 'Front End Development Libraries Certification', - certSlug: Certification.FrontEndDevLibs - }, - { - show: is2018DataVisCert, - title: 'Data Visualization Certification', - certSlug: Certification.DataVis - }, - { - show: isRelationalDatabaseCertV8, - title: 'Relational Database Certification', - certSlug: Certification.RelationalDb - }, - { - show: isApisMicroservicesCert, - title: 'Back End Development and APIs Certification', - certSlug: Certification.BackEndDevApis - }, - { - show: isQaCertV7, - title: 'Quality Assurance Certification', - certSlug: Certification.QualityAssurance - }, - { - show: isSciCompPyCertV7, - title: 'Scientific Computing with Python Certification', - certSlug: Certification.SciCompPy - }, - { - show: isDataAnalysisPyCertV7, - title: 'Data Analysis with Python Certification', - certSlug: Certification.DataAnalysisPy - }, - { - show: isInfosecCertV7, - title: 'Information Security Certification', - certSlug: Certification.InfoSec - }, - { - show: isMachineLearningPyCertV7, - title: 'Machine Learning with Python Certification', - certSlug: Certification.MachineLearningPy - }, - { - show: isCollegeAlgebraPyCertV8, - title: 'College Algebra with Python Certification', - certSlug: Certification.CollegeAlgebraPy - }, - { - show: isFoundationalCSharpCertV8, - title: 'Foundational C# with Microsoft Certification', - certSlug: Certification.FoundationalCSharp - } - ], - legacyCerts: [ - { - show: isFrontEndCert, - title: 'Front End Certification', - certSlug: Certification.LegacyFrontEnd - }, - { - show: isJsAlgoDataStructCert, - title: 'Legacy JavaScript Algorithms and Data Structures Certification', - certSlug: Certification.JsAlgoDataStruct - }, - { - show: isBackEndCert, - title: 'Back End Certification', - certSlug: Certification.LegacyBackEnd - }, - { - show: isDataVisCert, - title: 'Data Visualization Certification', - certSlug: Certification.LegacyDataVis - }, - { - show: isInfosecQaCert, - title: 'Information Security and Quality Assurance Certification', - // Keep the current public profile cert slug - certSlug: Certification.LegacyInfoSecQa - }, - { - show: isFullStackCert, - title: 'Full Stack Certification', - // Keep the current public profile cert slug - certSlug: Certification.LegacyFullStack - } - ] - }; +export const createUserByNameSelector = username => state => { + const sessionUser = userSelector(state); + const otherUser = otherUserSelector(state); + const isSessionUser = sessionUser?.username === username; + const isOtherUser = otherUser?.username === username; + const user = isSessionUser ? sessionUser : isOtherUser ? otherUser : null; + return user; }; export const userFetchStateSelector = state => state[MainApp].userFetchState; @@ -343,15 +185,11 @@ export const completionStateSelector = createSelector( ); export const userProfileFetchStateSelector = state => state[MainApp].userProfileFetchState; -export const usernameSelector = state => state[MainApp].appUsername; +export const usernameSelector = state => userSelector(state)?.username ?? ''; export const themeSelector = state => state[MainApp].theme; -export const userThemeSelector = state => { - return userSelector(state).theme; -}; -export const userSelector = state => { - const username = usernameSelector(state); +export const userThemeSelector = state => userSelector(state)?.theme; - return state[MainApp].user[username] || {}; -}; +export const userSelector = state => state[MainApp].user.sessionUser; +export const otherUserSelector = state => state[MainApp].user.otherUser; export const renderStartTimeSelector = state => state[MainApp].renderStartTime; diff --git a/client/src/redux/settings/settings-sagas.js b/client/src/redux/settings/settings-sagas.js index 610235a335f..1b778ab2845 100644 --- a/client/src/redux/settings/settings-sagas.js +++ b/client/src/redux/settings/settings-sagas.js @@ -8,6 +8,7 @@ import { takeLatest } from 'redux-saga/effects'; import store from 'store'; +import { navigate } from 'gatsby'; import { certTypeIdMap, @@ -69,6 +70,8 @@ function* submitNewUsernameSaga({ payload: username }) { try { const { data } = yield call(putUpdateMyUsername, username); yield put(submitNewUsernameComplete({ ...data, username })); + // When the username is updated, the user would otherwise still be on their old profile: + navigate(`/${username}`); yield put(createFlashMessage(data)); } catch (e) { yield put(submitNewUsernameError(e)); diff --git a/client/src/redux/types.ts b/client/src/redux/types.ts index 1e9f935aade..1e56de54090 100644 --- a/client/src/redux/types.ts +++ b/client/src/redux/types.ts @@ -12,7 +12,6 @@ export type FlashMessageArg = { export interface State { [FlashApp]: FlashState; [MainApp]: { - appUsername: string; recentlyClaimedBlock: null | string; showMultipleProgressModals: boolean; currentChallengId: string; diff --git a/client/src/templates/Challenges/components/hotkeys.tsx b/client/src/templates/Challenges/components/hotkeys.tsx index ee2fdb20a1b..21688a7a44e 100644 --- a/client/src/templates/Challenges/components/hotkeys.tsx +++ b/client/src/templates/Challenges/components/hotkeys.tsx @@ -7,8 +7,8 @@ import { createSelector } from 'reselect'; import type { ChallengeFiles, Test, - User, - ChallengeMeta + ChallengeMeta, + User } from '../../../redux/prop-types'; import { userSelector } from '../../../redux/selectors'; import { @@ -49,7 +49,7 @@ const mapStateToProps = createSelector( canFocusEditor: boolean, challengeFiles: ChallengeFiles, tests: Test[], - user: User, + user: User | null, { nextChallengePath, prevChallengePath }: ChallengeMeta ) => ({ isHelpModalOpen, @@ -59,7 +59,7 @@ const mapStateToProps = createSelector( canFocusEditor, challengeFiles, tests, - user, + keyboardShortcuts: !!user?.keyboardShortcuts, nextChallengePath, prevChallengePath }) @@ -101,7 +101,7 @@ export type HotkeysProps = Pick< setIsAdvancing: (arg0: boolean) => void; openShortcutsModal: () => void; playScene?: () => void; - user: User; + keyboardShortcuts: boolean; }; function Hotkeys({ @@ -121,7 +121,7 @@ function Hotkeys({ usesMultifileEditor, openShortcutsModal, playScene, - user: { keyboardShortcuts }, + keyboardShortcuts, isHelpModalOpen, isResetModalOpen, isShortcutsModalOpen, diff --git a/client/src/templates/Challenges/components/shortcuts-modal.tsx b/client/src/templates/Challenges/components/shortcuts-modal.tsx index 346151b4aa0..e7121906bf5 100644 --- a/client/src/templates/Challenges/components/shortcuts-modal.tsx +++ b/client/src/templates/Challenges/components/shortcuts-modal.tsx @@ -9,7 +9,7 @@ import { closeModal } from '../redux/actions'; import { isShortcutsModalOpenSelector } from '../redux/selectors'; import { updateMyKeyboardShortcuts } from '../../../redux/settings/actions'; import { userSelector } from '../../../redux/selectors'; -import { User } from '../../../redux/prop-types'; +import type { User } from '../../../redux/prop-types'; import KeyboardShortcutsSettings from '../../../components/settings/keyboard-shortcuts'; import './shortcuts-modal.css'; @@ -19,13 +19,16 @@ interface ShortcutsModalProps { toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void; isOpen: boolean; t: (text: string) => string; - user: User; + keyboardShortcuts: boolean; } const mapStateToProps = createSelector( isShortcutsModalOpenSelector, userSelector, - (isOpen: boolean, user: User) => ({ isOpen, user }) + (isOpen: boolean, user: User | null) => ({ + isOpen, + keyboardShortcuts: !!user?.keyboardShortcuts + }) ); const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( @@ -42,7 +45,7 @@ function ShortcutsModal({ toggleKeyboardShortcuts, isOpen, t, - user: { keyboardShortcuts } + keyboardShortcuts }: ShortcutsModalProps): JSX.Element { return ( diff --git a/client/src/templates/Introduction/components/cert-challenge.tsx b/client/src/templates/Introduction/components/cert-challenge.tsx index fe555b9b03e..36bdfdeb2b5 100644 --- a/client/src/templates/Introduction/components/cert-challenge.tsx +++ b/client/src/templates/Introduction/components/cert-challenge.tsx @@ -12,14 +12,14 @@ import { SuperBlocks } from '../../../../../shared/config/curriculum'; import { isSignedInSelector, - userFetchStateSelector, - currentCertsSelector + userFetchStateSelector } from '../../../redux/selectors'; -import { User, Steps } from '../../../redux/prop-types'; +import { User } from '../../../redux/prop-types'; import { type CertTitle, liveCerts } from '../../../../config/cert-and-project-map'; +import { getCertifications } from '../../../components/profile/components/utils/certification'; interface CertChallengeProps { // TODO: create enum/reuse SuperBlocks enum somehow @@ -31,7 +31,6 @@ interface CertChallengeProps { error: null | string; }; isSignedIn: boolean; - currentCerts: Steps['currentCerts']; superBlock: SuperBlocks; title: CertTitle; user: User; @@ -39,15 +38,9 @@ interface CertChallengeProps { const mapStateToProps = (state: unknown) => { return createSelector( - currentCertsSelector, userFetchStateSelector, isSignedInSelector, - ( - currentCerts, - fetchState: CertChallengeProps['fetchState'], - isSignedIn - ) => ({ - currentCerts, + (fetchState: CertChallengeProps['fetchState'], isSignedIn) => ({ fetchState, isSignedIn }) @@ -55,17 +48,19 @@ const mapStateToProps = (state: unknown) => { }; const CertChallenge = ({ - currentCerts, superBlock, title, fetchState, isSignedIn, - user: { username } + user }: CertChallengeProps): JSX.Element => { const { t } = useTranslation(); const [isCertified, setIsCertified] = useState(false); const [userLoaded, setUserLoaded] = useState(false); + const { currentCerts } = getCertifications(user); + const { username } = user; + const cert = liveCerts.find(x => x.title === title); if (!cert) throw Error(`Certification ${title} not found`); const certSlug = cert.certSlug; diff --git a/client/src/templates/Introduction/super-block-intro.tsx b/client/src/templates/Introduction/super-block-intro.tsx index a12524a3546..51dced68295 100644 --- a/client/src/templates/Introduction/super-block-intro.tsx +++ b/client/src/templates/Introduction/super-block-intro.tsx @@ -84,7 +84,7 @@ type SuperBlockProps = { resetExpansion: () => void; toggleBlock: (arg0: string) => void; tryToShowDonationModal: () => void; - user: User; + user: User | null; }; configureAnchors({ offset: -40, scrollDuration: 0 }); @@ -101,7 +101,7 @@ const mapStateToProps = (state: Record) => { isSignedIn, signInLoading: boolean, fetchState: FetchState, - user: User + user: User | null ) => ({ currentChallengeId, isSignedIn, @@ -147,8 +147,7 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => { signInLoading, user, pageContext: { superBlock, title, certification }, - location, - user: { completedChallenges: allCompletedChallenges } + location } = props; const allChallenges = useMemo( @@ -163,10 +162,10 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => { const completedChallenges = useMemo( () => - allCompletedChallenges.filter(completedChallenge => + (user?.completedChallenges ?? []).filter(completedChallenge => superBlockChallenges.some(c => c.id === completedChallenge.id) ), - [superBlockChallenges, allCompletedChallenges] + [superBlockChallenges, user?.completedChallenges] ); const i18nTitle = i18next.t(`intro:${superBlock}.title`); @@ -251,7 +250,7 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => { onCertificationDonationAlertClick={ onCertificationDonationAlertClick } - isDonating={user.isDonating} + isDonating={user?.isDonating ?? false} /> @@ -284,7 +283,7 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => { /> ); })} - {showCertification && ( + {showCertification && !!user && ( ( /** GET **/ -interface SessionUser { - user?: { [username: string]: User }; -} - type CompleteChallengeFromApi = { files: Array & { key: string }>; } & Omit; @@ -105,26 +101,19 @@ type SavedChallengeFromApi = { files: Array & { key: string }>; } & Omit; -type ApiSessionResponse = Omit; -type ApiUser = { +type ApiUser = Omit & { + completedChallenges?: CompleteChallengeFromApi[]; + savedChallenges?: SavedChallengeFromApi[]; +}; + +type ApiUserResponse = { user: { - [username: string]: Omit< - User, - 'completedChallenges' & 'savedChallenges' - > & { - completedChallenges?: CompleteChallengeFromApi[]; - savedChallenges?: SavedChallengeFromApi[]; - }; + [username: string]: ApiUser; }; result?: string; }; -type UserResponse = { - user: { [username: string]: User } | Record; - result: string | undefined; -}; - -function parseApiResponseToClientUser(data: ApiUser): UserResponse { +function parseApiResponseToClientUser(data: ApiUserResponse): User | null { const userData = data.user?.[data?.result ?? '']; let completedChallenges: CompletedChallenge[] = []; let savedChallenges: SavedChallenge[] = []; @@ -134,12 +123,9 @@ function parseApiResponseToClientUser(data: ApiUser): UserResponse { ); savedChallenges = mapFilesToChallengeFiles(userData.savedChallenges); } - return { - user: { - [data.result ?? '']: { ...userData, completedChallenges, savedChallenges } - }, - result: data.result - }; + return data.result + ? { ...userData, completedChallenges, savedChallenges } + : null; } // TODO: this at least needs a few aliases so it's human readable @@ -158,44 +144,38 @@ function mapKeyToFileKey( return files.map(({ key, ...rest }) => ({ ...rest, fileKey: key })); } -export function getSessionUser(): Promise> { - const responseWithData: Promise< - ResponseWithData - > = get('/user/get-session-user'); +export function getSessionUser(): Promise> { + const responseWithData: Promise> = get( + '/user/get-session-user' + ); // TODO: Once DB is migrated, no longer need to parse `files` -> `challengeFiles` etc. return responseWithData.then(({ response, data }) => { - const { result, user } = parseApiResponseToClientUser(data); + const user = parseApiResponseToClientUser(data); return { response, - data: { - result, - user - } + data: user }; }); } type UserProfileResponse = { - entities: Omit; + entities: Omit; result: string | undefined; }; export function getUserProfile( username: string -): Promise> { - const responseWithData = get<{ entities?: ApiUser; result?: string }>( +): Promise> { + const responseWithData = get( `/users/get-public-profile?username=${username}` ); return responseWithData.then(({ response, data }) => { - const { result, user } = parseApiResponseToClientUser({ + const user = parseApiResponseToClientUser({ user: data.entities?.user ?? {}, result: data.result }); return { response, - data: { - entities: { user }, - result - } + data: user }; }); } diff --git a/e2e/username-change.spec.ts b/e2e/username-change.spec.ts index 201d3788fb5..b70385eeeb1 100644 --- a/e2e/username-change.spec.ts +++ b/e2e/username-change.spec.ts @@ -112,6 +112,7 @@ test.describe('Username Settings Validation', () => { await expect( page.getByRole('alert').filter({ hasText: flashText }).first() ).toBeVisible(); + await expect(page).toHaveURL(`/${settingsObject.usernameAvailable}`); }); test('should update username in lowercase and reflect in the UI', async ({