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,
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 <RedirectHome />;
}
if (pending) {
if (pending || !user) {
return <Loader fullScreen={true} />;
}
@@ -376,8 +381,6 @@ const ShowCertification = (props: ShowCertificationProps): JSX.Element => {
completionTime
} = cert;
const { user } = props;
const displayName = userFullName ?? username;
const certDate = new Date(date);

View File

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

View File

@@ -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 <Loader fullScreen={true} />;
}
@@ -138,6 +117,36 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
navigate(`${apiLocation}/signin`);
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 editorLayout = (store.get('challenge-layout') as boolean) ?? false;
return (

View File

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

View File

@@ -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
})

View File

@@ -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<PaypalButtonProps, PaypalButtonState> {
const mapStateToProps = createSelector(
userSelector,
signInLoadingSelector,
({ isDonating }: { isDonating: boolean }, showLoading: boolean) => ({
isDonating,
(user: User | null, showLoading: boolean) => ({
isDonating: !!user?.isDonating,
showLoading
})
);

View File

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

View File

@@ -27,6 +27,7 @@ describe('<Intro />', () => {
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(),

View File

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

View File

@@ -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 (
<GrowthBookProvider growthbook={growthbook}>

View File

@@ -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<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 {
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 (
<FullWidthRow className='profile-certifications'>
@@ -122,4 +94,4 @@ function Certificates({
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() {
return {
app: { appUsername: 'vasili', user: { vasili: userProps.user } }
app: { user: { sessionUser: userProps.user } }
};
}
function renderWithRedux(ui: JSX.Element) {

View File

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

View File

@@ -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);

View File

@@ -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<string, unknown>;
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 = () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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));

View File

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

View File

@@ -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,

View File

@@ -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 (
<Modal onClose={closeShortcutsModal} open={isOpen}>

View File

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

View File

@@ -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<string, unknown>) => {
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}
/>
<HelpTranslate superBlock={superBlock} />
<Spacer size='l' />
@@ -284,7 +283,7 @@ const SuperBlockIntroductionPage = (props: SuperBlockProps) => {
/>
);
})}
{showCertification && (
{showCertification && !!user && (
<CertChallenge
certification={certification}
superBlock={superBlock}

View File

@@ -93,10 +93,6 @@ async function request<T>(
/** GET **/
interface SessionUser {
user?: { [username: string]: User };
}
type CompleteChallengeFromApi = {
files: Array<Omit<ChallengeFile, 'fileKey'> & { key: string }>;
} & Omit<CompletedChallenge, 'challengeFiles'>;
@@ -105,26 +101,19 @@ type SavedChallengeFromApi = {
files: Array<Omit<SavedChallengeFile, 'fileKey'> & { key: string }>;
} & Omit<SavedChallenge, 'challengeFiles'>;
type ApiSessionResponse = Omit<SessionUser, 'user'>;
type ApiUser = {
type ApiUser = Omit<User, 'completedChallenges' & 'savedChallenges'> & {
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<string, never>;
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<K>(
return files.map(({ key, ...rest }) => ({ ...rest, fileKey: key }));
}
export function getSessionUser(): Promise<ResponseWithData<SessionUser>> {
const responseWithData: Promise<
ResponseWithData<ApiUser & ApiSessionResponse>
> = get('/user/get-session-user');
export function getSessionUser(): Promise<ResponseWithData<User | null>> {
const responseWithData: Promise<ResponseWithData<ApiUserResponse>> = 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<UserResponse, 'result'>;
entities: Omit<ApiUserResponse, 'result'>;
result: string | undefined;
};
export function getUserProfile(
username: string
): Promise<ResponseWithData<UserProfileResponse>> {
const responseWithData = get<{ entities?: ApiUser; result?: string }>(
): Promise<ResponseWithData<User | null>> {
const responseWithData = get<UserProfileResponse>(
`/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
};
});
}

View File

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