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 ({