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;
certDashedName: string;
certSlug: string;
createFlashMessage: (payload: typeof standardErrorMessage) => void;
createFlashMessage: typeof createFlashMessage;
executeGA: (payload: Record<string, unknown>) => void;
fetchProfileForUser: (username: string) => void;
fetchState: {

View File

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

View File

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

View File

@ -1,41 +1,66 @@
import { nanoid } from 'nanoid';
import { createAction, handleActions } from 'redux-actions';
import { FlashState, State } from '../../../redux/types';
import { createTypes } from '../../../utils/create-types';
export const ns = 'flash';
export const FlashApp = 'flash';
const initialState = {
message: {}
message: {
id: '',
type: '',
message: ''
}
};
export const types = createTypes(
['createFlashMessage', 'removeFlashMessage'],
ns
);
export const sagas = [];
export const createFlashMessage = createAction(
types.createFlashMessage,
(msg: string[]) => ({ id: nanoid(), ...msg })
);
export const removeFlashMessage = createAction(types.removeFlashMessage);
export const flashMessageSelector = (state: State): FlashState['message'] =>
state[FlashApp].message;
// TODO: Once state is typed, add here, remove disable.
// eslint-disable-next-line
export const flashMessageSelector = (state: any): string => state[ns].message;
// ACTION DEFINITIONS
export const reducer = handleActions(
{
[types.createFlashMessage]: (state, { payload }) => ({
...state,
message: payload
}),
[types.removeFlashMessage]: state => ({
...state,
message: {}
})
},
initialState
);
enum FlashActionTypes {
createFlashMessage = 'createFlashMessage',
removeFlashMessage = 'removeFlashMessage'
}
export type FlashMessageArg = {
type: string;
message: string;
variables?: Record<string, unknown>;
};
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}
/>
{hasMessage && flashMessage ? (
<Flash flashMessage={flashMessage} onClose={removeFlashMessage} />
<Flash
flashMessage={flashMessage}
removeFlashMessage={removeFlashMessage}
/>
) : null}
{children}
</div>

View File

@ -5,7 +5,7 @@ import store from 'store';
import { actionTypes as challengeTypes } from '../templates/Challenges/redux/action-types';
import { CURRENT_CHALLENGE_KEY } from '../templates/Challenges/redux/current-challenge-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 { createDonationSaga } from './donation-saga';
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 updateCompleteEpic from './update-complete-epic';
export { ns };
export const MainApp = 'app';
export const defaultFetchState = {
pending: true,
@ -172,8 +172,9 @@ export const updateCurrentChallengeId = createAction(
export const completedChallengesSelector = state =>
userSelector(state).completedChallenges || [];
export const completionCountSelector = state => state[ns].completionCount;
export const currentChallengeIdSelector = state => state[ns].currentChallengeId;
export const completionCountSelector = state => state[MainApp].completionCount;
export const currentChallengeIdSelector = state =>
state[MainApp].currentChallengeId;
export const stepsToClaimSelector = state => {
const user = userSelector(state);
const currentCerts = certificatesByNameSelector(user.username)(
@ -188,21 +189,24 @@ export const stepsToClaimSelector = state => {
};
};
export const isDonatingSelector = state => userSelector(state).isDonating;
export const isOnlineSelector = state => state[ns].isOnline;
export const isServerOnlineSelector = state => state[ns].isServerOnline;
export const isSignedInSelector = state => !!state[ns].appUsername;
export const isDonationModalOpenSelector = state => state[ns].showDonationModal;
export const isOnlineSelector = state => state[MainApp].isOnline;
export const isServerOnlineSelector = state => state[MainApp].isServerOnline;
export const isSignedInSelector = state => !!state[MainApp].appUsername;
export const isDonationModalOpenSelector = state =>
state[MainApp].showDonationModal;
export const recentlyClaimedBlockSelector = state =>
state[ns].recentlyClaimedBlock;
export const donationFormStateSelector = state => state[ns].donationFormState;
state[MainApp].recentlyClaimedBlock;
export const donationFormStateSelector = state =>
state[MainApp].donationFormState;
export const signInLoadingSelector = state =>
userFetchStateSelector(state).pending;
export const showCertSelector = state => state[ns].showCert;
export const showCertFetchStateSelector = state => state[ns].showCertFetchState;
export const showCertSelector = state => state[MainApp].showCert;
export const showCertFetchStateSelector = state =>
state[MainApp].showCertFetchState;
export const shouldRequestDonationSelector = state => {
const completedChallenges = completedChallengesSelector(state);
const completionCount = completionCountSelector(state);
const canRequestProgressDonation = state[ns].canRequestProgressDonation;
const canRequestProgressDonation = state[MainApp].canRequestProgressDonation;
const isDonating = isDonatingSelector(state);
const recentlyClaimedBlock = recentlyClaimedBlockSelector(state);
@ -226,7 +230,7 @@ export const shouldRequestDonationSelector = state => {
};
export const userByNameSelector = username => state => {
const { user } = state[ns];
const { user } = state[MainApp];
// return initial state empty user empty object instead of empty
// object litteral to prevent components from re-rendering unnecessarily
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 =>
state[ns].userProfileFetchState;
export const usernameSelector = state => state[ns].appUsername;
state[MainApp].userProfileFetchState;
export const usernameSelector = state => state[MainApp].appUsername;
export const userSelector = 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) {
return {

View File

@ -2,7 +2,7 @@ import { combineReducers } from 'redux';
import {
reducer as flash,
ns as flashNameSpace
FlashApp as flashNameSpace
} from '../components/Flash/redux';
import {
reducer as search,
@ -17,7 +17,7 @@ import {
ns as curriculumMapNameSpace
} from '../templates/Introduction/redux';
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({
[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: ''
};