feat(client): refactor Flash/redux into TS (#42725)

* feat(client): ts-migrate Flash/redux

* add app types

* convert Flash/redux/index to vanilla TS

* update redux types.ts

* use FlashState type over State type

* update typing

* fix: prettier errors I caused?

* fix: re-add comment I removed

* remove comment

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

* fix type to not include {}

* remove commented out code for future use

* remove unused initialState object

* rename Flash onClose prop to match action name

* directly type reducer to return state

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>

Co-authored-by: moT01 <20648924+moT01@users.noreply.github.com>
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Shaun Hamilton
2021-10-20 18:58:10 +01:00
committed by GitHub
parent 8e43c3c838
commit d100132f75
8 changed files with 150 additions and 75 deletions

View File

@ -48,7 +48,7 @@ interface IShowCertificationProps {
cert: CertType; cert: CertType;
certDashedName: string; certDashedName: string;
certSlug: string; certSlug: string;
createFlashMessage: (payload: typeof standardErrorMessage) => void; createFlashMessage: typeof createFlashMessage;
executeGA: (payload: Record<string, unknown>) => void; executeGA: (payload: Record<string, unknown>) => void;
fetchProfileForUser: (username: string) => void; fetchProfileForUser: (username: string) => void;
fetchState: { fetchState: {

View File

@ -29,7 +29,7 @@ const { apiLocation } = envData;
// TODO: update types for actions // TODO: update types for actions
interface IShowSettingsProps { interface IShowSettingsProps {
createFlashMessage: (paylaod: string[]) => void; createFlashMessage: typeof createFlashMessage;
isSignedIn: boolean; isSignedIn: boolean;
navigate: (location: string) => void; navigate: (location: string) => void;
showLoading: boolean; showLoading: boolean;

View File

@ -1,23 +1,19 @@
import { Alert } from '@freecodecamp/react-bootstrap'; import { Alert } from '@freecodecamp/react-bootstrap';
import PropTypes from 'prop-types';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { TransitionGroup, CSSTransition } from 'react-transition-group'; import { TransitionGroup, CSSTransition } from 'react-transition-group';
import { FlashState } from '../../redux/types';
import { removeFlashMessage } from './redux';
import './flash.css'; import './flash.css';
type FlashProps = { type FlashProps = {
flashMessage: { flashMessage: FlashState['message'];
type: string; removeFlashMessage: typeof removeFlashMessage;
message: string;
id: string;
variables: Record<string, unknown>;
};
onClose: () => void;
}; };
function Flash({ flashMessage, onClose }: FlashProps): JSX.Element { function Flash({ flashMessage, removeFlashMessage }: FlashProps): JSX.Element {
const { type, message, id, variables = {} } = flashMessage; const { type, message, id, variables } = flashMessage;
const { t } = useTranslation(); const { t } = useTranslation();
const [flashMessageHeight, setFlashMessageHeight] = useState(0); const [flashMessageHeight, setFlashMessageHeight] = useState(0);
@ -33,7 +29,7 @@ function Flash({ flashMessage, onClose }: FlashProps): JSX.Element {
function handleClose() { function handleClose() {
document.documentElement.style.setProperty('--flash-message-height', '0px'); document.documentElement.style.setProperty('--flash-message-height', '0px');
onClose(); removeFlashMessage();
} }
return ( return (
@ -62,14 +58,5 @@ function Flash({ flashMessage, onClose }: FlashProps): JSX.Element {
} }
Flash.displayName = 'FlashMessages'; Flash.displayName = 'FlashMessages';
Flash.propTypes = {
flashMessage: PropTypes.shape({
id: PropTypes.string,
type: PropTypes.string,
message: PropTypes.string,
variables: PropTypes.object
}),
onClose: PropTypes.func.isRequired
};
export default Flash; export default Flash;

View File

@ -1,41 +1,66 @@
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { createAction, handleActions } from 'redux-actions'; import { FlashState, State } from '../../../redux/types';
import { createTypes } from '../../../utils/create-types'; export const FlashApp = 'flash';
export const ns = 'flash';
const initialState = { const initialState = {
message: {} message: {
id: '',
type: '',
message: ''
}
}; };
export const types = createTypes(
['createFlashMessage', 'removeFlashMessage'],
ns
);
export const sagas = []; export const sagas = [];
export const createFlashMessage = createAction( export const flashMessageSelector = (state: State): FlashState['message'] =>
types.createFlashMessage, state[FlashApp].message;
(msg: string[]) => ({ id: nanoid(), ...msg })
);
export const removeFlashMessage = createAction(types.removeFlashMessage);
// TODO: Once state is typed, add here, remove disable. // ACTION DEFINITIONS
// eslint-disable-next-line
export const flashMessageSelector = (state: any): string => state[ns].message;
export const reducer = handleActions( enum FlashActionTypes {
{ createFlashMessage = 'createFlashMessage',
[types.createFlashMessage]: (state, { payload }) => ({ removeFlashMessage = 'removeFlashMessage'
...state, }
message: payload
}), export type FlashMessageArg = {
[types.removeFlashMessage]: state => ({ type: string;
...state, message: string;
message: {} variables?: Record<string, unknown>;
}) };
},
initialState export const createFlashMessage = (
); flash: FlashMessageArg
): ReducerPayload<FlashActionTypes.createFlashMessage> => ({
type: FlashActionTypes.createFlashMessage,
payload: { ...flash, id: nanoid() }
});
export const removeFlashMessage =
(): ReducerPayload<FlashActionTypes.removeFlashMessage> => ({
type: FlashActionTypes.removeFlashMessage
});
// REDUCER
type ReducerBase<T> = { type: T };
type ReducerPayload<T extends FlashActionTypes> =
T extends FlashActionTypes.createFlashMessage
? ReducerBase<T> & {
payload: FlashState['message'];
}
: ReducerBase<T>;
// Does reducer return FlashState or AppState (whole app)?
export const reducer = (
state: FlashState = initialState,
action: ReducerPayload<FlashActionTypes>
): FlashState => {
switch (action.type) {
case FlashActionTypes.createFlashMessage:
return { ...state, message: action.payload };
case FlashActionTypes.removeFlashMessage:
return { ...state, message: initialState.message };
default:
return state;
}
};

View File

@ -217,7 +217,10 @@ class DefaultLayout extends Component {
isSignedIn={isSignedIn} isSignedIn={isSignedIn}
/> />
{hasMessage && flashMessage ? ( {hasMessage && flashMessage ? (
<Flash flashMessage={flashMessage} onClose={removeFlashMessage} /> <Flash
flashMessage={flashMessage}
removeFlashMessage={removeFlashMessage}
/>
) : null} ) : null}
{children} {children}
</div> </div>

View File

@ -5,7 +5,7 @@ import store from 'store';
import { actionTypes as challengeTypes } from '../templates/Challenges/redux/action-types'; import { actionTypes as challengeTypes } from '../templates/Challenges/redux/action-types';
import { CURRENT_CHALLENGE_KEY } from '../templates/Challenges/redux/current-challenge-saga'; import { CURRENT_CHALLENGE_KEY } from '../templates/Challenges/redux/current-challenge-saga';
import { createAcceptTermsSaga } from './accept-terms-saga'; import { createAcceptTermsSaga } from './accept-terms-saga';
import { actionTypes, ns } from './action-types'; import { actionTypes } from './action-types';
import { createAppMountSaga } from './app-mount-saga'; import { createAppMountSaga } from './app-mount-saga';
import { createDonationSaga } from './donation-saga'; import { createDonationSaga } from './donation-saga';
import failedUpdatesEpic from './failed-updates-epic'; import failedUpdatesEpic from './failed-updates-epic';
@ -19,7 +19,7 @@ import { actionTypes as settingsTypes } from './settings/action-types';
import { createShowCertSaga } from './show-cert-saga'; import { createShowCertSaga } from './show-cert-saga';
import updateCompleteEpic from './update-complete-epic'; import updateCompleteEpic from './update-complete-epic';
export { ns }; export const MainApp = 'app';
export const defaultFetchState = { export const defaultFetchState = {
pending: true, pending: true,
@ -172,8 +172,9 @@ export const updateCurrentChallengeId = createAction(
export const completedChallengesSelector = state => export const completedChallengesSelector = state =>
userSelector(state).completedChallenges || []; userSelector(state).completedChallenges || [];
export const completionCountSelector = state => state[ns].completionCount; export const completionCountSelector = state => state[MainApp].completionCount;
export const currentChallengeIdSelector = state => state[ns].currentChallengeId; export const currentChallengeIdSelector = state =>
state[MainApp].currentChallengeId;
export const stepsToClaimSelector = state => { export const stepsToClaimSelector = state => {
const user = userSelector(state); const user = userSelector(state);
const currentCerts = certificatesByNameSelector(user.username)( const currentCerts = certificatesByNameSelector(user.username)(
@ -188,21 +189,24 @@ export const stepsToClaimSelector = state => {
}; };
}; };
export const isDonatingSelector = state => userSelector(state).isDonating; export const isDonatingSelector = state => userSelector(state).isDonating;
export const isOnlineSelector = state => state[ns].isOnline; export const isOnlineSelector = state => state[MainApp].isOnline;
export const isServerOnlineSelector = state => state[ns].isServerOnline; export const isServerOnlineSelector = state => state[MainApp].isServerOnline;
export const isSignedInSelector = state => !!state[ns].appUsername; export const isSignedInSelector = state => !!state[MainApp].appUsername;
export const isDonationModalOpenSelector = state => state[ns].showDonationModal; export const isDonationModalOpenSelector = state =>
state[MainApp].showDonationModal;
export const recentlyClaimedBlockSelector = state => export const recentlyClaimedBlockSelector = state =>
state[ns].recentlyClaimedBlock; state[MainApp].recentlyClaimedBlock;
export const donationFormStateSelector = state => state[ns].donationFormState; export const donationFormStateSelector = state =>
state[MainApp].donationFormState;
export const signInLoadingSelector = state => export const signInLoadingSelector = state =>
userFetchStateSelector(state).pending; userFetchStateSelector(state).pending;
export const showCertSelector = state => state[ns].showCert; export const showCertSelector = state => state[MainApp].showCert;
export const showCertFetchStateSelector = state => state[ns].showCertFetchState; export const showCertFetchStateSelector = state =>
state[MainApp].showCertFetchState;
export const shouldRequestDonationSelector = state => { export const shouldRequestDonationSelector = state => {
const completedChallenges = completedChallengesSelector(state); const completedChallenges = completedChallengesSelector(state);
const completionCount = completionCountSelector(state); const completionCount = completionCountSelector(state);
const canRequestProgressDonation = state[ns].canRequestProgressDonation; const canRequestProgressDonation = state[MainApp].canRequestProgressDonation;
const isDonating = isDonatingSelector(state); const isDonating = isDonatingSelector(state);
const recentlyClaimedBlock = recentlyClaimedBlockSelector(state); const recentlyClaimedBlock = recentlyClaimedBlockSelector(state);
@ -226,7 +230,7 @@ export const shouldRequestDonationSelector = state => {
}; };
export const userByNameSelector = username => state => { export const userByNameSelector = username => state => {
const { user } = state[ns]; const { user } = state[MainApp];
// return initial state empty user empty object instead of empty // return initial state empty user empty object instead of empty
// object litteral to prevent components from re-rendering unnecessarily // object litteral to prevent components from re-rendering unnecessarily
return user[username] ?? initialState.user; return user[username] ?? initialState.user;
@ -357,17 +361,17 @@ export const certificatesByNameSelector = username => state => {
}; };
}; };
export const userFetchStateSelector = state => state[ns].userFetchState; export const userFetchStateSelector = state => state[MainApp].userFetchState;
export const userProfileFetchStateSelector = state => export const userProfileFetchStateSelector = state =>
state[ns].userProfileFetchState; state[MainApp].userProfileFetchState;
export const usernameSelector = state => state[ns].appUsername; export const usernameSelector = state => state[MainApp].appUsername;
export const userSelector = state => { export const userSelector = state => {
const username = usernameSelector(state); const username = usernameSelector(state);
return state[ns].user[username] || {}; return state[MainApp].user[username] || {};
}; };
export const sessionMetaSelector = state => state[ns].sessionMeta; export const sessionMetaSelector = state => state[MainApp].sessionMeta;
function spreadThePayloadOnUser(state, payload) { function spreadThePayloadOnUser(state, payload) {
return { return {

View File

@ -2,7 +2,7 @@ import { combineReducers } from 'redux';
import { import {
reducer as flash, reducer as flash,
ns as flashNameSpace FlashApp as flashNameSpace
} from '../components/Flash/redux'; } from '../components/Flash/redux';
import { import {
reducer as search, reducer as search,
@ -17,7 +17,7 @@ import {
ns as curriculumMapNameSpace ns as curriculumMapNameSpace
} from '../templates/Introduction/redux'; } from '../templates/Introduction/redux';
import { reducer as settings, ns as settingsNameSpace } from './settings'; import { reducer as settings, ns as settingsNameSpace } from './settings';
import { reducer as app, ns as appNameSpace } from './'; import { reducer as app, MainApp as appNameSpace } from './';
export default combineReducers({ export default combineReducers({
[appNameSpace]: app, [appNameSpace]: app,

56
client/src/redux/types.ts Normal file
View File

@ -0,0 +1,56 @@
import { FlashApp, FlashMessageArg } from '../components/Flash/redux';
import { MainApp } from '.';
export interface State {
[FlashApp]: FlashState;
[MainApp]: {
appUsername: string;
recentlyClaimedBlock: null | string;
canRequestProgressDonation: boolean;
completionCount: number;
currentChallengId: string;
showCert: Record<string, unknown>;
showCertFetchState: DefaultFetchState;
user: Record<string, unknown>;
userFetchState: DefaultFetchState;
userProfileFetchState: DefaultFetchState;
sessionMeta: {
activeDonations: number;
};
showDonationModal: boolean;
isOnline: boolean;
donationFormState: DefaultDonationFormState;
};
}
export interface FlashState {
message: { id: string } & FlashMessageArg;
}
export interface DefaultFetchState {
pending: boolean;
complete: boolean;
errored: boolean;
error: null | string;
}
export interface DefaultDonationFormState {
redirecting: boolean;
processing: boolean;
success: boolean;
error: null | string;
}
export const defaultFetchState = {
pending: true,
complete: false,
errored: false,
error: null
};
export const defaultDonationFormState = {
redirecting: false,
processing: false,
success: false,
error: ''
};