refactor(client): store session user in dedicated key (#59954)

This commit is contained in:
Oliver Eyton-Williams
2025-07-28 14:55:14 +02:00
committed by GitHub
parent df32414406
commit 3e1da8f3fb
30 changed files with 413 additions and 527 deletions

View File

@@ -21,10 +21,11 @@ import {
showCertFetchStateSelector, showCertFetchStateSelector,
userFetchStateSelector, userFetchStateSelector,
isDonatingSelector, isDonatingSelector,
userByNameSelector, usernameSelector,
usernameSelector createUserByNameSelector,
isSignedInSelector
} from '../redux/selectors'; } 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 { liveCerts } from '../../config/cert-and-project-map';
import { import {
certificateMissingErrorMessage, certificateMissingErrorMessage,
@@ -67,6 +68,7 @@ interface ShowCertificationProps {
}; };
isDonating: boolean; isDonating: boolean;
isValidCert: boolean; isValidCert: boolean;
isSignedIn: boolean;
location: { location: {
pathname: string; pathname: string;
}; };
@@ -78,41 +80,48 @@ interface ShowCertificationProps {
certSlug: string; certSlug: string;
}) => void; }) => void;
signedInUserName: string; signedInUserName: string;
user: User; user: User | null;
userFetchState: UserFetchState; userFetchState: UserFetchState;
userFullName: string; userFullName: string;
username: string; username: string;
} }
const requestedUserSelector = (state: unknown, { username = '' }) =>
userByNameSelector(username.toLowerCase())(state) as User;
const mapStateToProps = (state: unknown, props: ShowCertificationProps) => { const mapStateToProps = (state: unknown, props: ShowCertificationProps) => {
const isValidCert = liveCerts.some( const isValidCert = liveCerts.some(
({ certSlug }) => String(certSlug) === props.certSlug ({ certSlug }) => String(certSlug) === props.certSlug
); );
const { username } = props;
const userByNameSelector = createUserByNameSelector(username) as (
state: unknown
) => User | null;
return createSelector( return createSelector(
showCertSelector, showCertSelector,
showCertFetchStateSelector, showCertFetchStateSelector,
usernameSelector, usernameSelector,
userByNameSelector,
userFetchStateSelector, userFetchStateSelector,
isDonatingSelector, isDonatingSelector,
requestedUserSelector, isSignedInSelector,
( (
cert: Cert, cert: Cert,
fetchState: ShowCertificationProps['fetchState'], fetchState: ShowCertificationProps['fetchState'],
signedInUserName: string, signedInUserName: string,
user: User | null,
userFetchState: UserFetchState, userFetchState: UserFetchState,
isDonating: boolean, isDonating: boolean,
user: User isSignedIn: boolean
) => ({ ) => ({
cert, cert,
fetchState, fetchState,
isValidCert, isValidCert,
signedInUserName, signedInUserName,
user,
userFetchState, userFetchState,
isDonating, isDonating,
user isSignedIn
}) })
); );
}; };
@@ -293,24 +302,19 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => {
userFetchState: { complete: userComplete }, userFetchState: { complete: userComplete },
signedInUserName, signedInUserName,
isDonating, isDonating,
isSignedIn,
cert: { username = '' }, cert: { username = '' },
fetchProfileForUser, fetchProfileForUser,
user user
} = props; } = props;
if (!signedInUserName || signedInUserName !== username) { const isSessionUser = isSignedIn && signedInUserName === username;
if (isEmpty(user) && username) {
fetchProfileForUser(username); if (isEmpty(user) && username) {
} fetchProfileForUser(username);
} }
if ( if (!isDonationDisplayed && userComplete && isSessionUser && !isDonating) {
!isDonationDisplayed &&
userComplete &&
signedInUserName &&
signedInUserName === username &&
!isDonating
) {
setIsDonationDisplayed(true); setIsDonationDisplayed(true);
callGA({ callGA({
event: 'donation_view', event: 'donation_view',
@@ -341,7 +345,8 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => {
isValidCert, isValidCert,
createFlashMessage, createFlashMessage,
signedInUserName, signedInUserName,
location: { pathname } location: { pathname },
user
} = props; } = props;
const { pending, complete, errored } = fetchState; const { pending, complete, errored } = fetchState;
@@ -359,7 +364,7 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => {
return <RedirectHome />; return <RedirectHome />;
} }
if (pending) { if (pending || !user) {
return <Loader fullScreen={true} />; return <Loader fullScreen={true} />;
} }
@@ -376,8 +381,6 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => {
completionTime completionTime
} = cert; } = cert;
const { user } = props;
const displayName = userFullName ?? username; const displayName = userFullName ?? username;
const certDate = new Date(date); const certDate = new Date(date);

View File

@@ -8,11 +8,11 @@ import Loader from '../components/helpers/loader';
import Profile from '../components/profile/profile'; import Profile from '../components/profile/profile';
import { fetchProfileForUser } from '../redux/actions'; import { fetchProfileForUser } from '../redux/actions';
import { import {
usernameSelector, userSelector,
userByNameSelector, userProfileFetchStateSelector,
userProfileFetchStateSelector createUserByNameSelector
} from '../redux/selectors'; } from '../redux/selectors';
import { User } from '../redux/prop-types'; import type { User } from '../redux/prop-types';
import { Socials } from '../components/profile/components/internet'; import { Socials } from '../components/profile/components/internet';
interface ShowProfileOrFourOhFourProps { interface ShowProfileOrFourOhFourProps {
@@ -20,38 +20,31 @@ interface ShowProfileOrFourOhFourProps {
updateMyPortfolio: () => void; updateMyPortfolio: () => void;
submitNewAbout: () => void; submitNewAbout: () => void;
updateMySocials: (formValues: Socials) => void; updateMySocials: (formValues: Socials) => void;
fetchState: {
pending: boolean;
complete: boolean;
errored: boolean;
};
isSessionUser: boolean; isSessionUser: boolean;
maybeUser?: string; maybeUser?: string;
requestedUser: User; requestedUser: User | null;
showLoading: boolean; showLoading: boolean;
} }
const createRequestedUserSelector =
() =>
(state: unknown, { maybeUser = '' }) =>
userByNameSelector(maybeUser.toLowerCase())(state) as User;
const createIsSessionUserSelector =
() =>
(state: unknown, { maybeUser = '' }) =>
maybeUser.toLowerCase() === usernameSelector(state);
const makeMapStateToProps = const makeMapStateToProps =
() => (state: unknown, props: ShowProfileOrFourOhFourProps) => { () =>
const requestedUserSelector = createRequestedUserSelector(); (state: unknown, { maybeUser = '' }) => {
const isSessionUserSelector = createIsSessionUserSelector(); const username = maybeUser.toLowerCase();
const fetchState = userProfileFetchStateSelector( const requestedUser = (
state createUserByNameSelector as (
) as ShowProfileOrFourOhFourProps['fetchState']; 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 { return {
requestedUser: requestedUserSelector(state, props), requestedUser,
isSessionUser: isSessionUserSelector(state, props), isSessionUser,
showLoading: fetchState.pending, showLoading: fetchState.pending
fetchState
}; };
}; };
@@ -70,10 +63,8 @@ function ShowProfileOrFourOhFour({
}: ShowProfileOrFourOhFourProps) { }: ShowProfileOrFourOhFourProps) {
useEffect(() => { useEffect(() => {
// If the user is not already in the store, fetch it // If the user is not already in the store, fetch it
if (isEmpty(requestedUser)) { if (isEmpty(requestedUser) && maybeUser) {
if (maybeUser) { fetchProfileForUser(maybeUser);
fetchProfileForUser(maybeUser);
}
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);

View File

@@ -26,7 +26,7 @@ import {
isSignedInSelector, isSignedInSelector,
userTokenSelector userTokenSelector
} from '../redux/selectors'; } from '../redux/selectors';
import { User } from '../redux/prop-types'; import type { User } from '../redux/prop-types';
import { import {
submitNewAbout, submitNewAbout,
updateMyHonesty, updateMyHonesty,
@@ -50,7 +50,7 @@ type ShowSettingsProps = {
toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void; toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void;
updateIsHonest: () => void; updateIsHonest: () => void;
updateQuincyEmail: (isSendQuincyEmail: boolean) => void; updateQuincyEmail: (isSendQuincyEmail: boolean) => void;
user: User; user: User | null;
verifyCert: typeof verifyCert; verifyCert: typeof verifyCert;
path?: string; path?: string;
userToken: string | null; userToken: string | null;
@@ -61,7 +61,12 @@ const mapStateToProps = createSelector(
userSelector, userSelector,
isSignedInSelector, isSignedInSelector,
userTokenSelector, userTokenSelector,
(showLoading: boolean, user: User, isSignedIn, userToken: string | null) => ({ (
showLoading: boolean,
user: User | null,
isSignedIn,
userToken: string | null
) => ({
showLoading, showLoading,
user, user,
isSignedIn, isSignedIn,
@@ -91,34 +96,7 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
toggleSoundMode, toggleSoundMode,
toggleKeyboardShortcuts, toggleKeyboardShortcuts,
resetEditorLayout, resetEditorLayout,
user: { 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
},
navigate, navigate,
showLoading, showLoading,
updateQuincyEmail, updateQuincyEmail,
@@ -126,11 +104,12 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
verifyCert, verifyCert,
userToken userToken
} = props; } = props;
const isSignedInRef = useRef(isSignedIn); const isSignedInRef = useRef(isSignedIn);
const examTokenFlag = useFeatureIsOn('exam-token-widget'); const examTokenFlag = useFeatureIsOn('exam-token-widget');
if (showLoading) { if (showLoading || !user) {
return <Loader fullScreen={true} />; return <Loader fullScreen={true} />;
} }
@@ -138,6 +117,36 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
navigate(`${apiLocation}/signin`); navigate(`${apiLocation}/signin`);
return <Loader fullScreen={true} />; return <Loader fullScreen={true} />;
} }
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 sound = (store.get('fcc-sound') as boolean) ?? false;
const editorLayout = (store.get('challenge-layout') as boolean) ?? false; const editorLayout = (store.get('challenge-layout') as boolean) ?? false;
return ( return (

View File

@@ -27,6 +27,7 @@ import { isSignedInSelector, userSelector } from '../redux/selectors';
import { hardGoTo as navigate } from '../redux/actions'; import { hardGoTo as navigate } from '../redux/actions';
import { updateMyEmail } from '../redux/settings/actions'; import { updateMyEmail } from '../redux/settings/actions';
import { maybeEmailRE } from '../utils'; import { maybeEmailRE } from '../utils';
import type { User } from '../redux/prop-types';
const { apiLocation } = envData; const { apiLocation } = envData;
@@ -41,11 +42,8 @@ interface ShowUpdateEmailProps {
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
userSelector, userSelector,
isSignedInSelector, isSignedInSelector,
( (user: User | null, isSignedIn) => ({
{ email, emailVerified }: { email: string; emailVerified: boolean }, isNewEmail: !user?.email || user.emailVerified,
isSignedIn
) => ({
isNewEmail: !email || emailVerified,
isSignedIn isSignedIn
}) })
); );

View File

@@ -25,7 +25,7 @@ import {
themeSelector themeSelector
} from '../../redux/selectors'; } from '../../redux/selectors';
import { LocalStorageThemes, DonateFormState } from '../../redux/types'; 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 { CENTS_IN_DOLLAR, formattedAmountLabel } from './utils';
import DonateCompletion from './donate-completion'; import DonateCompletion from './donate-completion';
import PatreonButton from './patreon-button'; import PatreonButton from './patreon-button';
@@ -62,7 +62,7 @@ type PostCharge = (data: {
type DonateFormProps = { type DonateFormProps = {
postCharge: PostCharge; postCharge: PostCharge;
defaultTheme?: LocalStorageThemes; defaultTheme?: LocalStorageThemes;
email: string; email?: string;
handleProcessing?: () => void; handleProcessing?: () => void;
editAmount?: () => void; editAmount?: () => void;
selectedDonationAmount?: DonationAmount; selectedDonationAmount?: DonationAmount;
@@ -91,7 +91,7 @@ const mapStateToProps = createSelector(
isSignedIn: DonateFormProps['isSignedIn'], isSignedIn: DonateFormProps['isSignedIn'],
isDonating: DonateFormProps['isDonating'], isDonating: DonateFormProps['isDonating'],
donationFormState: DonateFormState, donationFormState: DonateFormState,
{ email }: { email: string }, user: User | null,
completedChallenges: CompletedChallenge[], completedChallenges: CompletedChallenge[],
theme: LocalStorageThemes theme: LocalStorageThemes
) => ({ ) => ({
@@ -99,7 +99,7 @@ const mapStateToProps = createSelector(
isDonating, isDonating,
showLoading, showLoading,
donationFormState, donationFormState,
email, email: user?.email,
completedChallenges, completedChallenges,
theme theme
}) })

View File

@@ -13,6 +13,7 @@ import {
import envData from '../../../config/env.json'; import envData from '../../../config/env.json';
import { userSelector, signInLoadingSelector } from '../../redux/selectors'; import { userSelector, signInLoadingSelector } from '../../redux/selectors';
import { LocalStorageThemes } from '../../redux/types'; import { LocalStorageThemes } from '../../redux/types';
import type { User } from '../../redux/prop-types';
import { DonationApprovalData, PostPayment } from './types'; import { DonationApprovalData, PostPayment } from './types';
import PayPalButtonScriptLoader from './paypal-button-script-loader'; import PayPalButtonScriptLoader from './paypal-button-script-loader';
@@ -177,8 +178,8 @@ class PaypalButton extends Component<PaypalButtonProps, PaypalButtonState> {
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
userSelector, userSelector,
signInLoadingSelector, signInLoadingSelector,
({ isDonating }: { isDonating: boolean }, showLoading: boolean) => ({ (user: User | null, showLoading: boolean) => ({
isDonating, isDonating: !!user?.isDonating,
showLoading showLoading
}) })
); );

View File

@@ -10,7 +10,7 @@ import LearnAlert from './learn-alert';
interface IntroProps { interface IntroProps {
complete?: boolean; complete?: boolean;
completedChallengeCount?: number; completedChallengeCount: number;
isSignedIn?: boolean; isSignedIn?: boolean;
name?: string; name?: string;
pending?: boolean; pending?: boolean;

View File

@@ -27,6 +27,7 @@ describe('<Intro />', () => {
const loggedInProps = { const loggedInProps = {
complete: true, complete: true,
completedChallengeCount: 0,
isSignedIn: true, isSignedIn: true,
name: 'Development User', name: 'Development User',
navigate: () => jest.fn(), navigate: () => jest.fn(),
@@ -39,6 +40,7 @@ const loggedInProps = {
const loggedOutProps = { const loggedOutProps = {
complete: true, complete: true,
completedChallengeCount: 0,
isSignedIn: false, isSignedIn: false,
name: '', name: '',
navigate: () => jest.fn(), navigate: () => jest.fn(),

View File

@@ -1,8 +1,6 @@
import i18next from 'i18next'; import i18next from 'i18next';
import { connect } from 'react-redux';
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { Spacer } from '@freecodecamp/ui'; import { Spacer } from '@freecodecamp/ui';
import { createSelector } from 'reselect';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -17,13 +15,6 @@ import { ButtonLink } from '../helpers';
import { showUpcomingChanges } from '../../../config/env.json'; import { showUpcomingChanges } from '../../../config/env.json';
import './map.css'; import './map.css';
import {
isSignedInSelector,
currentCertsSelector,
completedChallengesIdsSelector
} from '../../redux/selectors';
interface MapProps { interface MapProps {
forLanding?: boolean; forLanding?: boolean;
} }
@@ -46,17 +37,6 @@ const superBlockHeadings: { [key in SuperBlockStage]: string } = {
[SuperBlockStage.Catalog]: 'landing.catalog-heading' [SuperBlockStage.Catalog]: 'landing.catalog-heading'
}; };
const mapStateToProps = createSelector(
isSignedInSelector,
currentCertsSelector,
completedChallengesIdsSelector,
(isSignedIn: boolean, currentCerts, completedChallengeIds: string[]) => ({
isSignedIn,
currentCerts,
completedChallengeIds
})
);
function MapLi({ function MapLi({
superBlock, superBlock,
landing = false landing = false
@@ -124,4 +104,4 @@ function Map({ forLanding = false }: MapProps) {
Map.displayName = 'Map'; Map.displayName = 'Map';
export default connect(mapStateToProps)(Map); export default Map;

View File

@@ -6,14 +6,10 @@ import {
} from '@growthbook/growthbook-react'; } from '@growthbook/growthbook-react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { import { userSelector, userFetchStateSelector } from '../../redux/selectors';
isSignedInSelector,
userSelector,
userFetchStateSelector
} from '../../redux/selectors';
import envData from '../../../config/env.json'; import envData from '../../../config/env.json';
import defaultGrowthBookFeatures from '../../../config/growthbook-features-default.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 { getUUID } from '../../utils/growthbook-cookie';
import callGA from '../../analytics/call-ga'; import callGA from '../../analytics/call-ga';
import GrowthBookReduxConnector from './growth-book-redux-connector'; import GrowthBookReduxConnector from './growth-book-redux-connector';
@@ -30,11 +26,9 @@ declare global {
} }
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
isSignedInSelector,
userSelector, userSelector,
userFetchStateSelector, userFetchStateSelector,
(isSignedIn, user: User, userFetchState: UserFetchState) => ({ (user: User | null, userFetchState: UserFetchState) => ({
isSignedIn,
user, user,
userFetchState userFetchState
}) })
@@ -56,7 +50,6 @@ interface UserAttributes {
const GrowthBookWrapper = ({ const GrowthBookWrapper = ({
children, children,
isSignedIn,
user, user,
userFetchState userFetchState
}: GrowthBookWrapper) => { }: GrowthBookWrapper) => {
@@ -105,7 +98,7 @@ const GrowthBookWrapper = ({
clientLocal: clientLocale clientLocal: clientLocale
}; };
if (isSignedIn) { if (user) {
userAttributes = { userAttributes = {
...userAttributes, ...userAttributes,
staff: user.email.includes('@freecodecamp'), staff: user.email.includes('@freecodecamp'),
@@ -116,7 +109,7 @@ const GrowthBookWrapper = ({
} }
growthbook.setAttributes(userAttributes); growthbook.setAttributes(userAttributes);
} }
}, [isSignedIn, user, userFetchState, growthbook]); }, [user, userFetchState, growthbook]);
return ( return (
<GrowthBookProvider growthbook={growthbook}> <GrowthBookProvider growthbook={growthbook}>

View File

@@ -1,42 +1,15 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { Spacer } from '@freecodecamp/ui'; import { Spacer } from '@freecodecamp/ui';
import { certificatesByNameSelector } from '../../../redux/selectors'; import type { CurrentCert, User } from '../../../redux/prop-types';
import type { CurrentCert } from '../../../redux/prop-types';
import { FullWidthRow, ButtonLink } from '../../helpers'; import { FullWidthRow, ButtonLink } from '../../helpers';
import { getCertifications } from './utils/certification';
import './certifications.css'; import './certifications.css';
const mapStateToProps = (
state: Record<string, unknown>,
props: CertificationProps
) =>
createSelector(
certificatesByNameSelector(props.username.toLowerCase()),
({
hasModernCert,
hasLegacyCert,
currentCerts,
legacyCerts
}: Pick<
CertificationProps,
'hasModernCert' | 'hasLegacyCert' | 'currentCerts' | 'legacyCerts'
>) => ({
hasModernCert,
hasLegacyCert,
currentCerts,
legacyCerts
})
)(state);
interface CertificationProps { interface CertificationProps {
currentCerts?: CurrentCert[]; user: User;
hasLegacyCert?: boolean;
hasModernCert?: boolean;
legacyCerts?: CurrentCert[];
username: string;
} }
interface CertButtonProps { interface CertButtonProps {
@@ -62,13 +35,12 @@ function CertButton({ username, cert }: CertButtonProps): JSX.Element {
); );
} }
function Certificates({ function Certificates({ user }: CertificationProps): JSX.Element {
currentCerts, const { username } = user;
legacyCerts,
hasLegacyCert, const { currentCerts, legacyCerts, hasLegacyCert, hasModernCert } =
hasModernCert, getCertifications(user);
username
}: CertificationProps): JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<FullWidthRow className='profile-certifications'> <FullWidthRow className='profile-certifications'>
@@ -122,4 +94,4 @@ function Certificates({
Certificates.displayName = 'Certifications'; Certificates.displayName = 'Certifications';
export default connect(mapStateToProps)(Certificates); export default Certificates;

View File

@@ -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
}
]
};
};

View File

@@ -81,7 +81,7 @@ const notMyProfileProps = {
}; };
function reducer() { function reducer() {
return { return {
app: { appUsername: 'vasili', user: { vasili: userProps.user } } app: { user: { sessionUser: userProps.user } }
}; };
} }
function renderWithRedux(ui: JSX.Element) { function renderWithRedux(ui: JSX.Element) {

View File

@@ -122,7 +122,7 @@ function UserProfile({ user, isSessionUser }: ProfileProps): JSX.Element {
{showPortfolio ? ( {showPortfolio ? (
<PortfolioProjects portfolioProjects={portfolio} /> <PortfolioProjects portfolioProjects={portfolio} />
) : null} ) : null}
{showCerts ? <Certifications username={username} /> : null} {showCerts ? <Certifications user={user} /> : null}
{showTimeLine ? ( {showTimeLine ? (
<Timeline completedMap={completedChallenges} username={username} /> <Timeline completedMap={completedChallenges} username={username} />
) : null} ) : null}

View File

@@ -17,6 +17,7 @@ import {
userSelector, userSelector,
isSignedInSelector isSignedInSelector
} from '../redux/selectors'; } from '../redux/selectors';
import type { User } from '../redux/prop-types';
import './email-sign-up.css'; import './email-sign-up.css';
interface AcceptPrivacyTermsProps { interface AcceptPrivacyTermsProps {
@@ -24,25 +25,18 @@ interface AcceptPrivacyTermsProps {
acceptedPrivacyTerms: boolean; acceptedPrivacyTerms: boolean;
isSignedIn: boolean; isSignedIn: boolean;
showLoading: boolean; showLoading: boolean;
completedChallengeCount?: number; completedChallengeCount: number;
} }
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
userSelector, userSelector,
isSignedInSelector, isSignedInSelector,
signInLoadingSelector, signInLoadingSelector,
( (user: User | null, isSignedIn: boolean, showLoading: boolean) => ({
{ acceptedPrivacyTerms: !!user?.acceptedPrivacyTerms,
acceptedPrivacyTerms,
completedChallengeCount
}: { acceptedPrivacyTerms: boolean; completedChallengeCount: number },
isSignedIn: boolean,
showLoading: boolean
) => ({
acceptedPrivacyTerms,
isSignedIn, isSignedIn,
showLoading, showLoading,
completedChallengeCount completedChallengeCount: user?.completedChallengeCount ?? 0
}) })
); );
const mapDispatchToProps = (dispatch: Dispatch) => const mapDispatchToProps = (dispatch: Dispatch) =>
@@ -109,7 +103,7 @@ function AcceptPrivacyTerms({
acceptedPrivacyTerms, acceptedPrivacyTerms,
isSignedIn, isSignedIn,
showLoading, showLoading,
completedChallengeCount = 0 completedChallengeCount
}: AcceptPrivacyTermsProps) { }: AcceptPrivacyTermsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const acceptedPrivacyRef = useRef(acceptedPrivacyTerms); const acceptedPrivacyRef = useRef(acceptedPrivacyTerms);

View File

@@ -23,18 +23,18 @@ interface FetchState {
errored: boolean; errored: boolean;
} }
interface User { type MaybeUser = {
name: string; name: string;
username: string; username: string;
completedChallengeCount: number; completedChallengeCount: number;
isDonating: boolean; isDonating: boolean;
} } | null;
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
userFetchStateSelector, userFetchStateSelector,
isSignedInSelector, isSignedInSelector,
userSelector, userSelector,
(fetchState: FetchState, isSignedIn: boolean, user: User) => ({ (fetchState: FetchState, isSignedIn: boolean, user: MaybeUser) => ({
fetchState, fetchState,
isSignedIn, isSignedIn,
user user
@@ -49,7 +49,7 @@ interface LearnPageProps {
isSignedIn: boolean; isSignedIn: boolean;
fetchState: FetchState; fetchState: FetchState;
state: Record<string, unknown>; state: Record<string, unknown>;
user: User; user: MaybeUser;
data: { data: {
challengeNode: { challengeNode: {
challenge: { challenge: {
@@ -59,10 +59,12 @@ interface LearnPageProps {
}; };
} }
const EMPTY_USER = { name: '', completedChallengeCount: 0, isDonating: false };
function LearnPage({ function LearnPage({
isSignedIn, isSignedIn,
fetchState: { pending, complete }, fetchState: { pending, complete },
user: { name = '', completedChallengeCount = 0, isDonating = false }, user,
data: { data: {
challengeNode: { challengeNode: {
challenge: { challenge: {
@@ -71,6 +73,8 @@ function LearnPage({
} }
} }
}: LearnPageProps) { }: LearnPageProps) {
const { name, completedChallengeCount, isDonating } = user ?? EMPTY_USER;
const { t } = useTranslation(); const { t } = useTranslation();
const onLearnDonationAlertClick = () => { const onLearnDonationAlertClick = () => {

View File

@@ -49,9 +49,8 @@ const analyticsDataMock = {
const signedInStoreMock = { const signedInStoreMock = {
app: { app: {
appUsername: 'devuser',
user: { user: {
devuser: { sessionUser: {
completedChallenges: [ completedChallenges: [
{ {
id: 'bd7123c8c441eddfaeb5bdef', id: 'bd7123c8c441eddfaeb5bdef',
@@ -81,11 +80,8 @@ const signedInStoreMock = {
const signedOutStoreMock = { const signedOutStoreMock = {
app: { app: {
appUsername: '',
user: { user: {
'': { sessionUser: null
completedChallenges: []
}
} }
} }
}; };
@@ -162,11 +158,8 @@ describe('donation-saga', () => {
const signedOutStoreMock = { const signedOutStoreMock = {
app: { app: {
appUsername: '',
user: { user: {
'': { sessionUser: null
completedChallenges: []
}
} }
} }
}; };

View File

@@ -28,7 +28,7 @@ const initialState = {
app: { app: {
isOnline: true, isOnline: true,
isServerOnline: true, isServerOnline: true,
appUsername: 'developmentuser' user: { sessionUser: {} }
} }
}; };

View File

@@ -9,12 +9,9 @@ import {
function* fetchSessionUser() { function* fetchSessionUser() {
try { try {
const { const { data: user } = yield call(getSessionUser);
data: { user = {}, result = '' }
} = yield call(getSessionUser);
const appUser = user[result] || {};
yield put(fetchUserComplete({ user: appUser, username: result })); yield put(fetchUserComplete({ user }));
} catch (e) { } catch (e) {
console.log('failed to fetch user', e); console.log('failed to fetch user', e);
yield put(fetchUserError(e)); yield put(fetchUserError(e));
@@ -25,13 +22,8 @@ function* fetchOtherUser({ payload: maybeUser = '' }) {
try { try {
const maybeUserLC = maybeUser.toLowerCase(); const maybeUserLC = maybeUser.toLowerCase();
const { const { data: otherUser } = yield call(getUserProfile, maybeUserLC);
data: { entities: { user = {} } = {}, result = '' } yield put(fetchProfileForUserComplete({ user: otherUser }));
} = yield call(getUserProfile, maybeUserLC);
const otherUser = user[result] || {};
yield put(
fetchProfileForUserComplete({ user: otherUser, username: result })
);
} catch (e) { } catch (e) {
yield put(fetchProfileForUserError(e)); yield put(fetchProfileForUserError(e));
} }

View File

@@ -50,7 +50,6 @@ export const defaultDonationFormState = {
}; };
const initialState = { const initialState = {
appUsername: '',
isRandomCompletionThreshold: false, isRandomCompletionThreshold: false,
donatableSectionRecentlyCompleted: null, donatableSectionRecentlyCompleted: null,
currentChallengeId: store.get(CURRENT_CHALLENGE_KEY), currentChallengeId: store.get(CURRENT_CHALLENGE_KEY),
@@ -61,7 +60,7 @@ const initialState = {
showCertFetchState: { showCertFetchState: {
...defaultFetchState ...defaultFetchState
}, },
user: {}, user: { sessionUser: null, otherUser: null },
userFetchState: { userFetchState: {
...defaultFetchState ...defaultFetchState
}, },
@@ -107,8 +106,8 @@ function spreadThePayloadOnUser(state, payload) {
...state, ...state,
user: { user: {
...state.user, ...state.user,
[state.appUsername]: { sessionUser: {
...state.user[state.appUsername], ...state.user.sessionUser,
...payload ...payload
} }
} }
@@ -118,13 +117,12 @@ function spreadThePayloadOnUser(state, payload) {
export const reducer = handleActions( export const reducer = handleActions(
{ {
[actionTypes.acceptTermsComplete]: (state, { payload }) => { [actionTypes.acceptTermsComplete]: (state, { payload }) => {
const { appUsername } = state;
return { return {
...state, ...state,
user: { user: {
...state.user, ...state.user,
[appUsername]: { sessionUser: {
...state.user[appUsername], ...state.user.sessionUser,
// TODO: the user accepts the privacy terms in practice during auth // TODO: the user accepts the privacy terms in practice during auth
// however, it's currently being used to track if they've accepted // however, it's currently being used to track if they've accepted
// or rejected the newsletter. Ideally this should be migrated, // or rejected the newsletter. Ideally this should be migrated,
@@ -132,7 +130,7 @@ export const reducer = handleActions(
acceptedPrivacyTerms: true, acceptedPrivacyTerms: true,
sendQuincyEmail: sendQuincyEmail:
payload === null payload === null
? state.user[appUsername].sendQuincyEmail ? state.user.sessionUser.sendQuincyEmail
: payload : payload
} }
} }
@@ -175,13 +173,12 @@ export const reducer = handleActions(
donationFormState: { ...defaultDonationFormState, processing: true } donationFormState: { ...defaultDonationFormState, processing: true }
}), }),
[actionTypes.postChargeComplete]: state => { [actionTypes.postChargeComplete]: state => {
const { appUsername } = state;
return { return {
...state, ...state,
user: { user: {
...state.user, ...state.user,
[appUsername]: { sessionUser: {
...state.user[appUsername], ...state.user.sessionUser,
isDonating: true isDonating: true
} }
}, },
@@ -205,18 +202,14 @@ export const reducer = handleActions(
...state, ...state,
userProfileFetchState: { ...defaultFetchState } userProfileFetchState: { ...defaultFetchState }
}), }),
[actionTypes.fetchUserComplete]: ( [actionTypes.fetchUserComplete]: (state, { payload: { user } }) => ({
state,
{ payload: { user, username } }
) => ({
...state, ...state,
user: { user: {
...state.user, ...state.user,
[username]: { ...user, sessionUser: true } sessionUser: user
}, },
appUsername: username,
currentChallengeId: currentChallengeId:
user.currentChallengeId || store.get(CURRENT_CHALLENGE_KEY), user?.currentChallengeId || store.get(CURRENT_CHALLENGE_KEY),
userFetchState: { userFetchState: {
pending: false, pending: false,
complete: true, complete: true,
@@ -235,15 +228,13 @@ export const reducer = handleActions(
}), }),
[actionTypes.fetchProfileForUserComplete]: ( [actionTypes.fetchProfileForUserComplete]: (
state, state,
{ payload: { user, username } } { payload: { user } }
) => { ) => {
const previousUserObject =
username in state.user ? state.user[username] : {};
return { return {
...state, ...state,
user: { user: {
...state.user, ...state.user,
[username]: { ...previousUserObject, ...user } otherUser: user
}, },
userProfileFetchState: { userProfileFetchState: {
...defaultFetchState, ...defaultFetchState,
@@ -291,8 +282,7 @@ export const reducer = handleActions(
}), }),
[actionTypes.resetUserData]: state => ({ [actionTypes.resetUserData]: state => ({
...state, ...state,
appUsername: '', user: { ...state.user, sessionUser: null }
user: {}
}), }),
[actionTypes.openSignoutModal]: state => ({ [actionTypes.openSignoutModal]: state => ({
...state, ...state,
@@ -335,15 +325,14 @@ export const reducer = handleActions(
let submittedchallenges = [ let submittedchallenges = [
{ ...submittedChallenge, completedDate: Date.now() } { ...submittedChallenge, completedDate: Date.now() }
]; ];
const { appUsername } = state;
return examResults && !examResults.passed return examResults && !examResults.passed
? { ? {
...state, ...state,
user: { user: {
...state.user, ...state.user,
[appUsername]: { sessionUser: {
...state.user[appUsername], ...state.user.sessionUser,
examResults examResults
} }
} }
@@ -352,12 +341,12 @@ export const reducer = handleActions(
...state, ...state,
user: { user: {
...state.user, ...state.user,
[appUsername]: { sessionUser: {
...state.user[appUsername], ...state.user.sessionUser,
completedChallenges: uniqBy( completedChallenges: uniqBy(
[ [
...submittedchallenges, ...submittedchallenges,
...state.user[appUsername].completedChallenges ...state.user.sessionUser.completedChallenges
], ],
'id' 'id'
), ),
@@ -369,13 +358,12 @@ export const reducer = handleActions(
}; };
}, },
[actionTypes.setMsUsername]: (state, { payload }) => { [actionTypes.setMsUsername]: (state, { payload }) => {
const { appUsername } = state;
return { return {
...state, ...state,
user: { user: {
...state.user, ...state.user,
[appUsername]: { sessionUser: {
...state.user[appUsername], ...state.user.sessionUser,
msUsername: payload msUsername: payload
} }
} }
@@ -388,26 +376,24 @@ export const reducer = handleActions(
}; };
}, },
[actionTypes.updateUserToken]: (state, { payload }) => { [actionTypes.updateUserToken]: (state, { payload }) => {
const { appUsername } = state;
return { return {
...state, ...state,
user: { user: {
...state.user, ...state.user,
[appUsername]: { sessionUser: {
...state.user[appUsername], ...state.user.sessionUser,
userToken: payload userToken: payload
} }
} }
}; };
}, },
[actionTypes.deleteUserTokenComplete]: state => { [actionTypes.deleteUserTokenComplete]: state => {
const { appUsername } = state;
return { return {
...state, ...state,
user: { user: {
...state.user, ...state.user,
[appUsername]: { sessionUser: {
...state.user[appUsername], ...state.user.sessionUser,
userToken: null userToken: null
} }
} }
@@ -426,13 +412,12 @@ export const reducer = handleActions(
}; };
}, },
[actionTypes.clearExamResults]: state => { [actionTypes.clearExamResults]: state => {
const { appUsername } = state;
return { return {
...state, ...state,
user: { user: {
...state.user, ...state.user,
[appUsername]: { sessionUser: {
...state.user[appUsername], ...state.user.sessionUser,
examResults: null examResults: null
} }
} }
@@ -442,14 +427,13 @@ export const reducer = handleActions(
state, state,
{ payload: { surveyResults } } { payload: { surveyResults } }
) => { ) => {
const { appUsername } = state; const { completedSurveys = [] } = state.user.sessionUser;
const { completedSurveys = [] } = state.user[appUsername];
return { return {
...state, ...state,
user: { user: {
...state.user, ...state.user,
[appUsername]: { sessionUser: {
...state.user[appUsername], ...state.user.sessionUser,
completedSurveys: [...completedSurveys, surveyResults] completedSurveys: [...completedSurveys, surveyResults]
} }
} }
@@ -460,13 +444,12 @@ export const reducer = handleActions(
currentChallengeId: payload currentChallengeId: payload
}), }),
[actionTypes.saveChallengeComplete]: (state, { payload }) => { [actionTypes.saveChallengeComplete]: (state, { payload }) => {
const { appUsername } = state;
return { return {
...state, ...state,
user: { user: {
...state.user, ...state.user,
[appUsername]: { sessionUser: {
...state.user[appUsername], ...state.user.sessionUser,
savedChallenges: payload savedChallenges: payload
} }
} }
@@ -478,8 +461,8 @@ export const reducer = handleActions(
...state, ...state,
user: { user: {
...state.user, ...state.user,
[state.appUsername]: { sessionUser: {
...state.user[state.appUsername], ...state.user.sessionUser,
username: payload username: payload
} }
} }
@@ -511,8 +494,8 @@ export const reducer = handleActions(
...state, ...state,
user: { user: {
...state.user, ...state.user,
[state.appUsername]: { sessionUser: {
...state.user[state.appUsername], ...state.user.sessionUser,
profileUI: { ...payload } profileUI: { ...payload }
} }
} }

View File

@@ -308,6 +308,7 @@ export type User = {
about: string; about: string;
acceptedPrivacyTerms: boolean; acceptedPrivacyTerms: boolean;
completedChallenges: CompletedChallenge[]; completedChallenges: CompletedChallenge[];
completedChallengeCount: number;
completedSurveys: SurveyResults[]; completedSurveys: SurveyResults[];
currentChallengeId: string; currentChallengeId: string;
email: string; email: string;

View File

@@ -1,26 +1,25 @@
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { Certification } from '../../../shared/config/certification-settings';
import superBlockStructure from '../../../curriculum/superblock-structure/full-stack.json'; import superBlockStructure from '../../../curriculum/superblock-structure/full-stack.json';
import { randomBetween } from '../utils/random-between'; import { randomBetween } from '../utils/random-between';
import { getSessionChallengeData } from '../utils/session-storage'; import { getSessionChallengeData } from '../utils/session-storage';
import { ns as MainApp } from './action-types'; import { ns as MainApp } from './action-types';
export const savedChallengesSelector = state => export const savedChallengesSelector = state =>
userSelector(state).savedChallenges || []; userSelector(state)?.savedChallenges || [];
export const completedChallengesSelector = state => export const completedChallengesSelector = state =>
userSelector(state).completedChallenges || []; userSelector(state)?.completedChallenges || [];
export const userIdSelector = state => userSelector(state).id; export const userIdSelector = state => userSelector(state)?.id;
export const partiallyCompletedChallengesSelector = state => export const partiallyCompletedChallengesSelector = state =>
userSelector(state).partiallyCompletedChallenges || []; userSelector(state)?.partiallyCompletedChallenges || [];
export const currentChallengeIdSelector = state => export const currentChallengeIdSelector = state =>
state[MainApp].currentChallengeId; state[MainApp].currentChallengeId;
export const isRandomCompletionThresholdSelector = state => export const isRandomCompletionThresholdSelector = state =>
state[MainApp].isRandomCompletionThreshold; 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 isOnlineSelector = state => state[MainApp].isOnline;
export const isServerOnlineSelector = state => state[MainApp].isServerOnline; export const isServerOnlineSelector = state => state[MainApp].isServerOnline;
export const isSignedInSelector = state => !!state[MainApp].appUsername; export const isSignedInSelector = state => !!userSelector(state);
export const isDonationModalOpenSelector = state => export const isDonationModalOpenSelector = state =>
state[MainApp].showDonationModal; state[MainApp].showDonationModal;
export const isSignoutModalOpenSelector = state => export const isSignoutModalOpenSelector = state =>
@@ -88,185 +87,28 @@ export const shouldRequestDonationSelector = state => {
} }
}; };
export const userTokenSelector = state => { export const userTokenSelector = state => userSelector(state)?.userToken;
return userSelector(state).userToken;
};
export const examInProgressSelector = state => { export const examInProgressSelector = state => state[MainApp].examInProgress;
return state[MainApp].examInProgress;
};
export const examResultsSelector = state => userSelector(state).examResults; export const examResultsSelector = state => userSelector(state)?.examResults;
export const msUsernameSelector = state => { export const msUsernameSelector = state => userSelector(state)?.msUsername;
return userSelector(state).msUsername;
};
export const completedSurveysSelector = state => export const completedSurveysSelector = state =>
userSelector(state).completedSurveys || []; userSelector(state)?.completedSurveys || [];
export const isProcessingSelector = state => { export const isProcessingSelector = state => {
return state[MainApp].isProcessing; return state[MainApp].isProcessing;
}; };
export const userByNameSelector = username => state => { export const createUserByNameSelector = username => state => {
const { user } = state[MainApp]; const sessionUser = userSelector(state);
// return initial state empty user empty object instead of empty const otherUser = otherUserSelector(state);
// object literal to prevent components from re-rendering unnecessarily const isSessionUser = sessionUser?.username === username;
// TODO: confirm if "initialState" can be moved here or action-types.js const isOtherUser = otherUser?.username === username;
return user[username] ?? {}; const user = isSessionUser ? sessionUser : isOtherUser ? otherUser : null;
}; return user;
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 userFetchStateSelector = state => state[MainApp].userFetchState; export const userFetchStateSelector = state => state[MainApp].userFetchState;
@@ -343,15 +185,11 @@ export const completionStateSelector = createSelector(
); );
export const userProfileFetchStateSelector = state => export const userProfileFetchStateSelector = state =>
state[MainApp].userProfileFetchState; 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 themeSelector = state => state[MainApp].theme;
export const userThemeSelector = state => { export const userThemeSelector = state => userSelector(state)?.theme;
return userSelector(state).theme;
};
export const userSelector = state => {
const username = usernameSelector(state);
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; export const renderStartTimeSelector = state => state[MainApp].renderStartTime;

View File

@@ -8,6 +8,7 @@ import {
takeLatest takeLatest
} from 'redux-saga/effects'; } from 'redux-saga/effects';
import store from 'store'; import store from 'store';
import { navigate } from 'gatsby';
import { import {
certTypeIdMap, certTypeIdMap,
@@ -69,6 +70,8 @@ function* submitNewUsernameSaga({ payload: username }) {
try { try {
const { data } = yield call(putUpdateMyUsername, username); const { data } = yield call(putUpdateMyUsername, username);
yield put(submitNewUsernameComplete({ ...data, 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)); yield put(createFlashMessage(data));
} catch (e) { } catch (e) {
yield put(submitNewUsernameError(e)); yield put(submitNewUsernameError(e));

View File

@@ -12,7 +12,6 @@ export type FlashMessageArg = {
export interface State { export interface State {
[FlashApp]: FlashState; [FlashApp]: FlashState;
[MainApp]: { [MainApp]: {
appUsername: string;
recentlyClaimedBlock: null | string; recentlyClaimedBlock: null | string;
showMultipleProgressModals: boolean; showMultipleProgressModals: boolean;
currentChallengId: string; currentChallengId: string;

View File

@@ -7,8 +7,8 @@ import { createSelector } from 'reselect';
import type { import type {
ChallengeFiles, ChallengeFiles,
Test, Test,
User, ChallengeMeta,
ChallengeMeta User
} from '../../../redux/prop-types'; } from '../../../redux/prop-types';
import { userSelector } from '../../../redux/selectors'; import { userSelector } from '../../../redux/selectors';
import { import {
@@ -49,7 +49,7 @@ const mapStateToProps = createSelector(
canFocusEditor: boolean, canFocusEditor: boolean,
challengeFiles: ChallengeFiles, challengeFiles: ChallengeFiles,
tests: Test[], tests: Test[],
user: User, user: User | null,
{ nextChallengePath, prevChallengePath }: ChallengeMeta { nextChallengePath, prevChallengePath }: ChallengeMeta
) => ({ ) => ({
isHelpModalOpen, isHelpModalOpen,
@@ -59,7 +59,7 @@ const mapStateToProps = createSelector(
canFocusEditor, canFocusEditor,
challengeFiles, challengeFiles,
tests, tests,
user, keyboardShortcuts: !!user?.keyboardShortcuts,
nextChallengePath, nextChallengePath,
prevChallengePath prevChallengePath
}) })
@@ -101,7 +101,7 @@ export type HotkeysProps = Pick<
setIsAdvancing: (arg0: boolean) => void; setIsAdvancing: (arg0: boolean) => void;
openShortcutsModal: () => void; openShortcutsModal: () => void;
playScene?: () => void; playScene?: () => void;
user: User; keyboardShortcuts: boolean;
}; };
function Hotkeys({ function Hotkeys({
@@ -121,7 +121,7 @@ function Hotkeys({
usesMultifileEditor, usesMultifileEditor,
openShortcutsModal, openShortcutsModal,
playScene, playScene,
user: { keyboardShortcuts }, keyboardShortcuts,
isHelpModalOpen, isHelpModalOpen,
isResetModalOpen, isResetModalOpen,
isShortcutsModalOpen, isShortcutsModalOpen,

View File

@@ -9,7 +9,7 @@ import { closeModal } from '../redux/actions';
import { isShortcutsModalOpenSelector } from '../redux/selectors'; import { isShortcutsModalOpenSelector } from '../redux/selectors';
import { updateMyKeyboardShortcuts } from '../../../redux/settings/actions'; import { updateMyKeyboardShortcuts } from '../../../redux/settings/actions';
import { userSelector } from '../../../redux/selectors'; 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 KeyboardShortcutsSettings from '../../../components/settings/keyboard-shortcuts';
import './shortcuts-modal.css'; import './shortcuts-modal.css';
@@ -19,13 +19,16 @@ interface ShortcutsModalProps {
toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void; toggleKeyboardShortcuts: (keyboardShortcuts: boolean) => void;
isOpen: boolean; isOpen: boolean;
t: (text: string) => string; t: (text: string) => string;
user: User; keyboardShortcuts: boolean;
} }
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
isShortcutsModalOpenSelector, isShortcutsModalOpenSelector,
userSelector, userSelector,
(isOpen: boolean, user: User) => ({ isOpen, user }) (isOpen: boolean, user: User | null) => ({
isOpen,
keyboardShortcuts: !!user?.keyboardShortcuts
})
); );
const mapDispatchToProps = (dispatch: Dispatch) => const mapDispatchToProps = (dispatch: Dispatch) =>
bindActionCreators( bindActionCreators(
@@ -42,7 +45,7 @@ function ShortcutsModal({
toggleKeyboardShortcuts, toggleKeyboardShortcuts,
isOpen, isOpen,
t, t,
user: { keyboardShortcuts } keyboardShortcuts
}: ShortcutsModalProps): JSX.Element { }: ShortcutsModalProps): JSX.Element {
return ( return (
<Modal onClose={closeShortcutsModal} open={isOpen}> <Modal onClose={closeShortcutsModal} open={isOpen}>

View File

@@ -12,14 +12,14 @@ import { SuperBlocks } from '../../../../../shared/config/curriculum';
import { import {
isSignedInSelector, isSignedInSelector,
userFetchStateSelector, userFetchStateSelector
currentCertsSelector
} from '../../../redux/selectors'; } from '../../../redux/selectors';
import { User, Steps } from '../../../redux/prop-types'; import { User } from '../../../redux/prop-types';
import { import {
type CertTitle, type CertTitle,
liveCerts liveCerts
} from '../../../../config/cert-and-project-map'; } from '../../../../config/cert-and-project-map';
import { getCertifications } from '../../../components/profile/components/utils/certification';
interface CertChallengeProps { interface CertChallengeProps {
// TODO: create enum/reuse SuperBlocks enum somehow // TODO: create enum/reuse SuperBlocks enum somehow
@@ -31,7 +31,6 @@ interface CertChallengeProps {
error: null | string; error: null | string;
}; };
isSignedIn: boolean; isSignedIn: boolean;
currentCerts: Steps['currentCerts'];
superBlock: SuperBlocks; superBlock: SuperBlocks;
title: CertTitle; title: CertTitle;
user: User; user: User;
@@ -39,15 +38,9 @@ interface CertChallengeProps {
const mapStateToProps = (state: unknown) => { const mapStateToProps = (state: unknown) => {
return createSelector( return createSelector(
currentCertsSelector,
userFetchStateSelector, userFetchStateSelector,
isSignedInSelector, isSignedInSelector,
( (fetchState: CertChallengeProps['fetchState'], isSignedIn) => ({
currentCerts,
fetchState: CertChallengeProps['fetchState'],
isSignedIn
) => ({
currentCerts,
fetchState, fetchState,
isSignedIn isSignedIn
}) })
@@ -55,17 +48,19 @@ const mapStateToProps = (state: unknown) => {
}; };
const CertChallenge = ({ const CertChallenge = ({
currentCerts,
superBlock, superBlock,
title, title,
fetchState, fetchState,
isSignedIn, isSignedIn,
user: { username } user
}: CertChallengeProps): JSX.Element => { }: CertChallengeProps): JSX.Element => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isCertified, setIsCertified] = useState(false); const [isCertified, setIsCertified] = useState(false);
const [userLoaded, setUserLoaded] = useState(false); const [userLoaded, setUserLoaded] = useState(false);
const { currentCerts } = getCertifications(user);
const { username } = user;
const cert = liveCerts.find(x => x.title === title); const cert = liveCerts.find(x => x.title === title);
if (!cert) throw Error(`Certification ${title} not found`); if (!cert) throw Error(`Certification ${title} not found`);
const certSlug = cert.certSlug; const certSlug = cert.certSlug;

View File

@@ -84,7 +84,7 @@ type SuperBlockProps = {
resetExpansion: () => void; resetExpansion: () => void;
toggleBlock: (arg0: string) => void; toggleBlock: (arg0: string) => void;
tryToShowDonationModal: () => void; tryToShowDonationModal: () => void;
user: User; user: User | null;
}; };
configureAnchors({ offset: -40, scrollDuration: 0 }); configureAnchors({ offset: -40, scrollDuration: 0 });
@@ -101,7 +101,7 @@ const mapStateToProps = (state: Record<string, unknown>) => {
isSignedIn, isSignedIn,
signInLoading: boolean, signInLoading: boolean,
fetchState: FetchState, fetchState: FetchState,
user: User user: User | null
) => ({ ) => ({
currentChallengeId, currentChallengeId,
isSignedIn, isSignedIn,
@@ -147,8 +147,7 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => {
signInLoading, signInLoading,
user, user,
pageContext: { superBlock, title, certification }, pageContext: { superBlock, title, certification },
location, location
user: { completedChallenges: allCompletedChallenges }
} = props; } = props;
const allChallenges = useMemo( const allChallenges = useMemo(
@@ -163,10 +162,10 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => {
const completedChallenges = useMemo( const completedChallenges = useMemo(
() => () =>
allCompletedChallenges.filter(completedChallenge => (user?.completedChallenges ?? []).filter(completedChallenge =>
superBlockChallenges.some(c => c.id === completedChallenge.id) superBlockChallenges.some(c => c.id === completedChallenge.id)
), ),
[superBlockChallenges, allCompletedChallenges] [superBlockChallenges, user?.completedChallenges]
); );
const i18nTitle = i18next.t(`intro:${superBlock}.title`); const i18nTitle = i18next.t(`intro:${superBlock}.title`);
@@ -251,7 +250,7 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => {
onCertificationDonationAlertClick={ onCertificationDonationAlertClick={
onCertificationDonationAlertClick onCertificationDonationAlertClick
} }
isDonating={user.isDonating} isDonating={user?.isDonating ?? false}
/> />
<HelpTranslate superBlock={superBlock} /> <HelpTranslate superBlock={superBlock} />
<Spacer size='l' /> <Spacer size='l' />
@@ -284,7 +283,7 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => {
/> />
); );
})} })}
{showCertification && ( {showCertification && !!user && (
<CertChallenge <CertChallenge
certification={certification} certification={certification}
superBlock={superBlock} superBlock={superBlock}

View File

@@ -93,10 +93,6 @@ async function request<T>(
/** GET **/ /** GET **/
interface SessionUser {
user?: { [username: string]: User };
}
type CompleteChallengeFromApi = { type CompleteChallengeFromApi = {
files: Array<Omit<ChallengeFile, 'fileKey'> & { key: string }>; files: Array<Omit<ChallengeFile, 'fileKey'> & { key: string }>;
} & Omit<CompletedChallenge, 'challengeFiles'>; } & Omit<CompletedChallenge, 'challengeFiles'>;
@@ -105,26 +101,19 @@ type SavedChallengeFromApi = {
files: Array<Omit<SavedChallengeFile, 'fileKey'> & { key: string }>; files: Array<Omit<SavedChallengeFile, 'fileKey'> & { key: string }>;
} & Omit<SavedChallenge, 'challengeFiles'>; } & Omit<SavedChallenge, 'challengeFiles'>;
type ApiSessionResponse = Omit<SessionUser, 'user'>; type ApiUser = Omit<User, 'completedChallenges' & 'savedChallenges'> & {
type ApiUser = { completedChallenges?: CompleteChallengeFromApi[];
savedChallenges?: SavedChallengeFromApi[];
};
type ApiUserResponse = {
user: { user: {
[username: string]: Omit< [username: string]: ApiUser;
User,
'completedChallenges' & 'savedChallenges'
> & {
completedChallenges?: CompleteChallengeFromApi[];
savedChallenges?: SavedChallengeFromApi[];
};
}; };
result?: string; result?: string;
}; };
type UserResponse = { function parseApiResponseToClientUser(data: ApiUserResponse): User | null {
user: { [username: string]: User } | Record<string, never>;
result: string | undefined;
};
function parseApiResponseToClientUser(data: ApiUser): UserResponse {
const userData = data.user?.[data?.result ?? '']; const userData = data.user?.[data?.result ?? ''];
let completedChallenges: CompletedChallenge[] = []; let completedChallenges: CompletedChallenge[] = [];
let savedChallenges: SavedChallenge[] = []; let savedChallenges: SavedChallenge[] = [];
@@ -134,12 +123,9 @@ function parseApiResponseToClientUser(data: ApiUser): UserResponse {
); );
savedChallenges = mapFilesToChallengeFiles(userData.savedChallenges); savedChallenges = mapFilesToChallengeFiles(userData.savedChallenges);
} }
return { return data.result
user: { ? { ...userData, completedChallenges, savedChallenges }
[data.result ?? '']: { ...userData, completedChallenges, savedChallenges } : null;
},
result: data.result
};
} }
// TODO: this at least needs a few aliases so it's human readable // TODO: this at least needs a few aliases so it's human readable
@@ -158,44 +144,38 @@ function mapKeyToFileKey<K>(
return files.map(({ key, ...rest }) => ({ ...rest, fileKey: key })); return files.map(({ key, ...rest }) => ({ ...rest, fileKey: key }));
} }
export function getSessionUser(): Promise<ResponseWithData<SessionUser>> { export function getSessionUser(): Promise<ResponseWithData<User | null>> {
const responseWithData: Promise< const responseWithData: Promise<ResponseWithData<ApiUserResponse>> = get(
ResponseWithData<ApiUser & ApiSessionResponse> '/user/get-session-user'
> = get('/user/get-session-user'); );
// TODO: Once DB is migrated, no longer need to parse `files` -> `challengeFiles` etc. // TODO: Once DB is migrated, no longer need to parse `files` -> `challengeFiles` etc.
return responseWithData.then(({ response, data }) => { return responseWithData.then(({ response, data }) => {
const { result, user } = parseApiResponseToClientUser(data); const user = parseApiResponseToClientUser(data);
return { return {
response, response,
data: { data: user
result,
user
}
}; };
}); });
} }
type UserProfileResponse = { type UserProfileResponse = {
entities: Omit<UserResponse, 'result'>; entities: Omit<ApiUserResponse, 'result'>;
result: string | undefined; result: string | undefined;
}; };
export function getUserProfile( export function getUserProfile(
username: string username: string
): Promise<ResponseWithData<UserProfileResponse>> { ): Promise<ResponseWithData<User | null>> {
const responseWithData = get<{ entities?: ApiUser; result?: string }>( const responseWithData = get<UserProfileResponse>(
`/users/get-public-profile?username=${username}` `/users/get-public-profile?username=${username}`
); );
return responseWithData.then(({ response, data }) => { return responseWithData.then(({ response, data }) => {
const { result, user } = parseApiResponseToClientUser({ const user = parseApiResponseToClientUser({
user: data.entities?.user ?? {}, user: data.entities?.user ?? {},
result: data.result result: data.result
}); });
return { return {
response, response,
data: { data: user
entities: { user },
result
}
}; };
}); });
} }

View File

@@ -112,6 +112,7 @@ test.describe('Username Settings Validation', () => {
await expect( await expect(
page.getByRole('alert').filter({ hasText: flashText }).first() page.getByRole('alert').filter({ hasText: flashText }).first()
).toBeVisible(); ).toBeVisible();
await expect(page).toHaveURL(`/${settingsObject.usernameAvailable}`);
}); });
test('should update username in lowercase and reflect in the UI', async ({ test('should update username in lowercase and reflect in the UI', async ({