Redux promise middleware (v2) (#233)

* add redux-promise-middleware to package.json and update package-lock.json

* intergrate redux-promise-middleware and simplify rates by replacing saga with promise

* fix unrelated breaking test

* -improve user messaging when network request fails. \n Clean up rates actions and reducers

* Address tslint errors
This commit is contained in:
Daniel Ternyak 2017-09-25 20:41:11 -07:00 committed by GitHub
parent 3660260efb
commit af84a589c5
19 changed files with 175 additions and 134 deletions

View File

@ -1,19 +1,11 @@
import * as interfaces from './actionTypes'; import * as interfaces from './actionTypes';
import { TypeKeys } from './constants'; import { TypeKeys } from './constants';
import { fetchRates, CCResponse } from './actionPayloads';
export type TFiatRequestedRates = typeof fiatRequestedRates; export type TFetchCCRates = typeof fetchCCRates;
export function fiatRequestedRates(): interfaces.FiatRequestedRatesAction { export function fetchCCRates(): interfaces.FetchCCRates {
return { return {
type: TypeKeys.RATES_FIAT_REQUESTED type: TypeKeys.RATES_FETCH_CC,
}; payload: fetchRates()
}
export type TFiatSucceededRates = typeof fiatSucceededRates;
export function fiatSucceededRates(payload: {
[key: string]: number;
}): interfaces.FiatSucceededRatesAction {
return {
type: TypeKeys.RATES_FIAT_SUCCEEDED,
payload
}; };
} }

View File

@ -0,0 +1,22 @@
import { handleJSONResponse } from 'api/utils';
export const symbols = ['USD', 'EUR', 'GBP', 'BTC', 'CHF', 'REP'];
const symbolsURL = symbols.join(',');
// TODO - internationalize
const ERROR_MESSAGE = 'Could not fetch rate data.';
const CCApi = 'https://min-api.cryptocompare.com';
const CCRates = CCSymbols => `${CCApi}/data/price?fsym=ETH&tsyms=${CCSymbols}`;
export interface CCResponse {
BTC: number;
EUR: number;
GBP: number;
CHF: number;
REP: number;
}
export const fetchRates = (): Promise<CCResponse> =>
fetch(CCRates(symbolsURL)).then(response =>
handleJSONResponse(response, ERROR_MESSAGE)
);

View File

@ -1,13 +1,23 @@
import { TypeKeys } from './constants'; import { TypeKeys } from './constants';
export interface FiatRequestedRatesAction { import { CCResponse } from './actionPayloads';
type: TypeKeys.RATES_FIAT_REQUESTED;
export interface FetchCCRates {
type: TypeKeys.RATES_FETCH_CC;
payload: Promise<CCResponse>;
} }
/*** Set rates ***/ /*** Set rates ***/
export interface FiatSucceededRatesAction { export interface FetchCCRatesSucceeded {
type: TypeKeys.RATES_FIAT_SUCCEEDED; type: TypeKeys.RATES_FETCH_CC_SUCCEEDED;
payload: { [key: string]: number }; payload: CCResponse;
}
export interface FetchCCRatesFailed {
type: TypeKeys.RATES_FETCH_CC_FAILED;
} }
/*** Union Type ***/ /*** Union Type ***/
export type RatesAction = FiatSucceededRatesAction | FiatRequestedRatesAction; export type RatesAction =
| FetchCCRatesSucceeded
| FetchCCRates
| FetchCCRatesFailed;

View File

@ -1,4 +1,5 @@
export enum TypeKeys { export enum TypeKeys {
RATES_FIAT_REQUESTED = 'RATES_FIAT_REQUESTED', RATES_FETCH_CC = 'RATES_FETCH_CC',
RATES_FIAT_SUCCEEDED = 'RATES_FIAT_SUCCEEDED' RATES_FETCH_CC_FAILED = 'RATES_FETCH_CC_FAILED',
RATES_FETCH_CC_SUCCEEDED = 'RATES_FETCH_CC_SUCCEEDED'
} }

View File

@ -1,2 +1,3 @@
export * from './actionCreators'; export * from './actionCreators';
export * from './actionTypes'; export * from './actionTypes';
export * from './actionPayloads';

View File

@ -2,10 +2,7 @@ export function checkHttpStatus(response) {
if (response.status >= 200 && response.status < 300) { if (response.status >= 200 && response.status < 300) {
return response; return response;
} else { } else {
const error = new Error(response.statusText); return new Error(response.statusText);
// TODO: why assign response?
// error.response = response;
throw error;
} }
} }
@ -15,8 +12,7 @@ export function parseJSON(response) {
export async function handleJSONResponse(response, errorMessage) { export async function handleJSONResponse(response, errorMessage) {
if (response.ok) { if (response.ok) {
const json = await response.json(); return await response.json();
return json;
} }
if (errorMessage) { if (errorMessage) {
throw new Error(errorMessage); throw new Error(errorMessage);

View File

@ -1,4 +1,4 @@
import { FiatRequestedRatesAction } from 'actions/rates'; import { TFetchCCRates } from 'actions/rates';
import { Identicon } from 'components/ui'; import { Identicon } from 'components/ui';
import { NetworkConfig } from 'config/data'; import { NetworkConfig } from 'config/data';
import { Ether } from 'libs/units'; import { Ether } from 'libs/units';
@ -12,7 +12,7 @@ interface Props {
balance: Ether; balance: Ether;
wallet: IWallet; wallet: IWallet;
network: NetworkConfig; network: NetworkConfig;
fiatRequestedRates(): FiatRequestedRatesAction; fetchCCRates: TFetchCCRates;
} }
interface State { interface State {
@ -26,9 +26,9 @@ export default class AccountInfo extends React.Component<Props, State> {
}; };
public componentDidMount() { public componentDidMount() {
this.props.fiatRequestedRates(); this.props.fetchCCRates();
this.props.wallet.getAddress().then(addr => { this.props.wallet.getAddress().then(address => {
this.setState({ address: addr }); this.setState({ address });
}); });
} }
@ -57,9 +57,7 @@ export default class AccountInfo extends React.Component<Props, State> {
<div className="AccountInfo-address-icon"> <div className="AccountInfo-address-icon">
<Identicon address={address} size="100%" /> <Identicon address={address} size="100%" />
</div> </div>
<div className="AccountInfo-address-addr"> <div className="AccountInfo-address-addr">{address}</div>
{address}
</div>
</div> </div>
</div> </div>
@ -82,26 +80,29 @@ export default class AccountInfo extends React.Component<Props, State> {
</ul> </ul>
</div> </div>
{(!!blockExplorer || !!tokenExplorer) && {(!!blockExplorer || !!tokenExplorer) && (
<div className="AccountInfo-section"> <div className="AccountInfo-section">
<h5 className="AccountInfo-section-header"> <h5 className="AccountInfo-section-header">
{translate('sidebar_TransHistory')} {translate('sidebar_TransHistory')}
</h5> </h5>
<ul className="AccountInfo-list"> <ul className="AccountInfo-list">
{!!blockExplorer && {!!blockExplorer && (
<li className="AccountInfo-list-item"> <li className="AccountInfo-list-item">
<a href={blockExplorer.address(address)} target="_blank"> <a href={blockExplorer.address(address)} target="_blank">
{`${network.name} (${blockExplorer.name})`} {`${network.name} (${blockExplorer.name})`}
</a> </a>
</li>} </li>
{!!tokenExplorer && )}
<li className="AccountInfo-list-item"> {!!tokenExplorer && (
<a href={tokenExplorer.address(address)} target="_blank"> <li className="AccountInfo-list-item">
{`Tokens (${tokenExplorer.name})`} <a href={tokenExplorer.address(address)} target="_blank">
</a> {`Tokens (${tokenExplorer.name})`}
</li>} </a>
</ul> </li>
</div>} )}
</ul>
</div>
)}
</div> </div>
); );
} }

View File

@ -3,27 +3,26 @@ import React from 'react';
import translate from 'translations'; import translate from 'translations';
import { formatNumber } from 'utils/formatters'; import { formatNumber } from 'utils/formatters';
import './EquivalentValues.scss'; import './EquivalentValues.scss';
import { State } from 'reducers/rates';
const ratesKeys = ['BTC', 'REP', 'EUR', 'USD', 'GBP', 'CHF']; import { symbols } from 'actions/rates';
interface Props { interface Props {
balance?: Ether; balance?: Ether;
rates?: { [key: string]: number }; rates?: State['rates'];
ratesError?: State['ratesError'];
} }
export default class EquivalentValues extends React.Component<Props, {}> { export default class EquivalentValues extends React.Component<Props, {}> {
public render() { public render() {
const { balance, rates } = this.props; const { balance, rates, ratesError } = this.props;
return ( return (
<div className="EquivalentValues"> <div className="EquivalentValues">
<h5 className="EquivalentValues-title"> <h5 className="EquivalentValues-title">{translate('sidebar_Equiv')}</h5>
{translate('sidebar_Equiv')}
</h5>
<ul className="EquivalentValues-values"> <ul className="EquivalentValues-values">
{rates {rates
? ratesKeys.map(key => { ? symbols.map(key => {
if (!rates[key]) { if (!rates[key]) {
return null; return null;
} }
@ -33,14 +32,15 @@ export default class EquivalentValues extends React.Component<Props, {}> {
{key}: {key}:
</span> </span>
<span className="EquivalentValues-values-currency-value"> <span className="EquivalentValues-values-currency-value">
{' '}{balance {' '}
{balance
? formatNumber(balance.amount.times(rates[key])) ? formatNumber(balance.amount.times(rates[key]))
: '???'} : '???'}
</span> </span>
</li> </li>
); );
}) })
: <h5>No rates were loaded.</h5>} : ratesError && <h5>{ratesError}</h5>}
</ul> </ul>
</div> </div>
); );

View File

@ -5,10 +5,7 @@ import {
TRemoveCustomToken TRemoveCustomToken
} from 'actions/customTokens'; } from 'actions/customTokens';
import { showNotification, TShowNotification } from 'actions/notifications'; import { showNotification, TShowNotification } from 'actions/notifications';
import { import { fetchCCRates as dFetchCCRates, TFetchCCRates } from 'actions/rates';
fiatRequestedRates as dFiatRequestedRates,
TFiatRequestedRates
} from 'actions/rates';
import { NetworkConfig } from 'config/data'; import { NetworkConfig } from 'config/data';
import { Ether } from 'libs/units'; import { Ether } from 'libs/units';
import { IWallet } from 'libs/wallet/IWallet'; import { IWallet } from 'libs/wallet/IWallet';
@ -25,17 +22,19 @@ import AccountInfo from './AccountInfo';
import EquivalentValues from './EquivalentValues'; import EquivalentValues from './EquivalentValues';
import Promos from './Promos'; import Promos from './Promos';
import TokenBalances from './TokenBalances'; import TokenBalances from './TokenBalances';
import { State } from 'reducers/rates';
interface Props { interface Props {
wallet: IWallet; wallet: IWallet;
balance: Ether; balance: Ether;
network: NetworkConfig; network: NetworkConfig;
tokenBalances: TokenBalance[]; tokenBalances: TokenBalance[];
rates: { [key: string]: number }; rates: State['rates'];
ratesError: State['ratesError'];
showNotification: TShowNotification; showNotification: TShowNotification;
addCustomToken: TAddCustomToken; addCustomToken: TAddCustomToken;
removeCustomToken: TRemoveCustomToken; removeCustomToken: TRemoveCustomToken;
fiatRequestedRates: TFiatRequestedRates; fetchCCRates: TFetchCCRates;
} }
interface Block { interface Block {
@ -52,7 +51,8 @@ export class BalanceSidebar extends React.Component<Props, {}> {
network, network,
tokenBalances, tokenBalances,
rates, rates,
fiatRequestedRates ratesError,
fetchCCRates
} = this.props; } = this.props;
if (!wallet) { if (!wallet) {
return null; return null;
@ -66,7 +66,7 @@ export class BalanceSidebar extends React.Component<Props, {}> {
wallet={wallet} wallet={wallet}
balance={balance} balance={balance}
network={network} network={network}
fiatRequestedRates={fiatRequestedRates} fetchCCRates={fetchCCRates}
/> />
) )
}, },
@ -87,20 +87,26 @@ export class BalanceSidebar extends React.Component<Props, {}> {
}, },
{ {
name: 'Equivalent Values', name: 'Equivalent Values',
content: <EquivalentValues balance={balance} rates={rates} /> content: (
<EquivalentValues
balance={balance}
rates={rates}
ratesError={ratesError}
/>
)
} }
]; ];
return ( return (
<aside> <aside>
{blocks.map(block => {blocks.map(block => (
<section <section
className={`Block ${block.isFullWidth ? 'is-full-width' : ''}`} className={`Block ${block.isFullWidth ? 'is-full-width' : ''}`}
key={block.name} key={block.name}
> >
{block.content} {block.content}
</section> </section>
)} ))}
</aside> </aside>
); );
} }
@ -112,7 +118,8 @@ function mapStateToProps(state: AppState) {
balance: state.wallet.balance, balance: state.wallet.balance,
tokenBalances: getTokenBalances(state), tokenBalances: getTokenBalances(state),
network: getNetworkConfig(state), network: getNetworkConfig(state),
rates: state.rates rates: state.rates.rates,
ratesError: state.rates.ratesError
}; };
} }
@ -120,5 +127,5 @@ export default connect(mapStateToProps, {
addCustomToken, addCustomToken,
removeCustomToken, removeCustomToken,
showNotification, showNotification,
fiatRequestedRates: dFiatRequestedRates fetchCCRates: dFetchCCRates
})(BalanceSidebar); })(BalanceSidebar);

View File

@ -104,8 +104,8 @@ export default class PaperWallet extends React.Component<Props, State> {
if (!this.props.wallet) { if (!this.props.wallet) {
return; return;
} }
this.props.wallet.getAddress().then(addr => { this.props.wallet.getAddress().then(address => {
this.setState({ address: addr }); this.setState({ address });
}); });
} }

View File

@ -27,8 +27,8 @@ export default class DownloadWallet extends Component<Props, State> {
}; };
public componentDidMount() { public componentDidMount() {
this.props.wallet.getAddress().then(addr => { this.props.wallet.getAddress().then(address => {
this.setState({ address: addr }); this.setState({ address });
}); });
} }

View File

@ -1,17 +1,33 @@
import { FiatSucceededRatesAction, RatesAction } from 'actions/rates'; import {
FetchCCRatesSucceeded,
FetchCCRatesFailed,
RatesAction,
CCResponse
} from 'actions/rates';
import { TypeKeys } from 'actions/rates/constants'; import { TypeKeys } from 'actions/rates/constants';
import { Optional } from 'utils/types';
// SYMBOL -> PRICE TO BUY 1 ETH // SYMBOL -> PRICE TO BUY 1 ETH
export interface State { export interface State {
[key: string]: number; rates?: Optional<CCResponse>;
ratesError?: string | null;
} }
export const INITIAL_STATE: State = {}; export const INITIAL_STATE: State = {};
function fiatSucceededRates( function fetchCCRatesSucceeded(
state: State, state: State,
action: FiatSucceededRatesAction action: FetchCCRatesSucceeded
): State { ): State {
return action.payload; return { ...state, rates: action.payload };
}
function fetchCCRatesFailed(state: State, action: FetchCCRatesFailed): State {
// TODO: Make library for error messages
return {
...state,
ratesError: 'Sorry. We were unable to fetch equivalent rates.'
};
} }
export function rates( export function rates(
@ -19,8 +35,10 @@ export function rates(
action: RatesAction action: RatesAction
): State { ): State {
switch (action.type) { switch (action.type) {
case TypeKeys.RATES_FIAT_SUCCEEDED: case TypeKeys.RATES_FETCH_CC_SUCCEEDED:
return fiatSucceededRates(state, action); return fetchCCRatesSucceeded(state, action);
case TypeKeys.RATES_FETCH_CC_FAILED:
return fetchCCRatesFailed(state, action);
default: default:
return state; return state;
} }

View File

@ -2,7 +2,6 @@ import handleConfigChanges from './config';
import contracts from './contracts'; import contracts from './contracts';
import deterministicWallets from './deterministicWallets'; import deterministicWallets from './deterministicWallets';
import notifications from './notifications'; import notifications from './notifications';
import rates from './rates';
import { import {
bityTimeRemaining, bityTimeRemaining,
pollBityOrderStatusSaga, pollBityOrderStatusSaga,
@ -19,7 +18,6 @@ export default {
getBityRatesSaga, getBityRatesSaga,
contracts, contracts,
notifications, notifications,
rates,
wallet, wallet,
deterministicWallets deterministicWallets
}; };

View File

@ -1,27 +0,0 @@
import { fiatSucceededRates } from 'actions/rates';
import { handleJSONResponse } from 'api/utils';
import { SagaIterator } from 'redux-saga';
import { call, put, takeLatest } from 'redux-saga/effects';
const symbols = ['USD', 'EUR', 'GBP', 'BTC', 'CHF', 'REP'];
const symbolsURL = symbols.join(',');
// TODO - internationalize
const ERROR_MESSAGE = 'Could not fetch rate data.';
const fetchRates = () =>
fetch(
`https://min-api.cryptocompare.com/data/price?fsym=ETH&tsyms=${symbolsURL}`
).then(response => handleJSONResponse(response, ERROR_MESSAGE));
export function* handleRatesRequest(): SagaIterator {
try {
const rates = yield call(fetchRates);
yield put(fiatSucceededRates(rates));
} catch (error) {
yield put({ type: 'RATES_FIAT_FAILED', payload: error });
}
}
export default function* ratesSaga(): SagaIterator {
yield takeLatest('RATES_FIAT_REQUESTED', handleRatesRequest);
}

View File

@ -1,5 +0,0 @@
import { Effect } from 'redux-saga/effects';
export type Yield = Effect | {};
export type Return = void;
export type Next = any;

View File

@ -8,14 +8,11 @@ import { applyMiddleware, createStore } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension'; import { composeWithDevTools } from 'redux-devtools-extension';
import { createLogger } from 'redux-logger'; import { createLogger } from 'redux-logger';
import createSagaMiddleware from 'redux-saga'; import createSagaMiddleware from 'redux-saga';
import { import { loadStatePropertyOrEmptyObject, saveState } from 'utils/localStorage';
loadState,
loadStatePropertyOrEmptyObject,
saveState
} from 'utils/localStorage';
import RootReducer from './reducers'; import RootReducer from './reducers';
import { State as CustomTokenState } from './reducers/customTokens'; import { State as CustomTokenState } from './reducers/customTokens';
import { State as SwapState } from './reducers/swap'; import { State as SwapState } from './reducers/swap';
import promiseMiddleware from 'redux-promise-middleware';
import sagas from './sagas'; import sagas from './sagas';
@ -27,17 +24,26 @@ const configureStore = () => {
collapsed: true collapsed: true
}); });
const sagaMiddleware = createSagaMiddleware(); const sagaMiddleware = createSagaMiddleware();
const reduxPromiseMiddleWare = promiseMiddleware({
promiseTypeSuffixes: ['REQUESTED', 'SUCCEEDED', 'FAILED']
});
let middleware; let middleware;
let store; let store;
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
(window as MyWindow).Perf = Perf; (window as MyWindow).Perf = Perf;
middleware = composeWithDevTools( middleware = composeWithDevTools(
applyMiddleware(sagaMiddleware, logger, routerMiddleware(history as any)) applyMiddleware(
sagaMiddleware,
logger,
reduxPromiseMiddleWare,
routerMiddleware(history as any)
)
); );
} else { } else {
middleware = applyMiddleware( middleware = applyMiddleware(
sagaMiddleware, sagaMiddleware,
reduxPromiseMiddleWare,
routerMiddleware(history as any) routerMiddleware(history as any)
); );
} }

4
common/utils/types.ts Normal file
View File

@ -0,0 +1,4 @@
// Maps interface keys to optional
export type Optional<T> = { [P in keyof T]?: T[P] };
// Maps interface keys to nullable
export type Nullable<T> = { [P in keyof T]: T[P] | null };

14
package-lock.json generated
View File

@ -145,6 +145,15 @@
"redux": "3.7.2" "redux": "3.7.2"
} }
}, },
"@types/redux-promise-middleware": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/@types/redux-promise-middleware/-/redux-promise-middleware-0.0.8.tgz",
"integrity": "sha512-5GFSEoerhY5EAXgtc276k3TOq8HSY4vmqI0AinzpejJlgRHW5Aw6gl2wmy0cqxoPqkhrd8yWbs2uxRa5FOMdvQ==",
"dev": true,
"requires": {
"redux": "3.7.2"
}
},
"@types/redux-saga": { "@types/redux-saga": {
"version": "0.10.5", "version": "0.10.5",
"resolved": "https://registry.npmjs.org/@types/redux-saga/-/redux-saga-0.10.5.tgz", "resolved": "https://registry.npmjs.org/@types/redux-saga/-/redux-saga-0.10.5.tgz",
@ -10878,6 +10887,11 @@
"deep-diff": "0.3.8" "deep-diff": "0.3.8"
} }
}, },
"redux-promise-middleware": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/redux-promise-middleware/-/redux-promise-middleware-4.4.1.tgz",
"integrity": "sha512-1B5eiSGbZIbTKutIwYBwvz8visA5Bdnqtlxm2UnVsl7pgyU7hc7wigBwj17cLOnvG6deO4yft9NXFjWpg7k7PQ=="
},
"redux-saga": { "redux-saga": {
"version": "0.15.4", "version": "0.15.4",
"resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-0.15.4.tgz", "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-0.15.4.tgz",

View File

@ -33,6 +33,7 @@
"redux": "^3.6.0", "redux": "^3.6.0",
"redux-form": "^6.6.3", "redux-form": "^6.6.3",
"redux-logger": "^3.0.1", "redux-logger": "^3.0.1",
"redux-promise-middleware": "^4.4.1",
"redux-saga": "^0.15.3", "redux-saga": "^0.15.3",
"scryptsy": "^2.0.0", "scryptsy": "^2.0.0",
"store2": "^2.5.5", "store2": "^2.5.5",
@ -56,6 +57,7 @@
"@types/react-router-redux": "^4.0.50", "@types/react-router-redux": "^4.0.50",
"@types/redux-form": "^7.0.5", "@types/redux-form": "^7.0.5",
"@types/redux-logger": "^3.0.3", "@types/redux-logger": "^3.0.3",
"@types/redux-promise-middleware": "0.0.8",
"@types/redux-saga": "^0.10.5", "@types/redux-saga": "^0.10.5",
"@types/uuid": "^3.4.2", "@types/uuid": "^3.4.2",
"@types/webpack-env": "^1.13.1", "@types/webpack-env": "^1.13.1",
@ -107,8 +109,7 @@
"db": "nodemon ./db", "db": "nodemon ./db",
"build": "webpack --config webpack_config/webpack.prod.js", "build": "webpack --config webpack_config/webpack.prod.js",
"prebuild": "check-node-version --package", "prebuild": "check-node-version --package",
"build:demo": "build:demo": "BUILD_GH_PAGES=true webpack --config webpack_config/webpack.prod.js",
"BUILD_GH_PAGES=true webpack --config webpack_config/webpack.prod.js",
"prebuild:demo": "check-node-version --package", "prebuild:demo": "check-node-version --package",
"test": "jest --config=jest_config/jest.config.json --coverage", "test": "jest --config=jest_config/jest.config.json --coverage",
"pretest": "check-node-version --package", "pretest": "check-node-version --package",
@ -116,14 +117,16 @@
"predev": "check-node-version --package", "predev": "check-node-version --package",
"dev:https": "HTTPS=true node webpack_config/server.js", "dev:https": "HTTPS=true node webpack_config/server.js",
"predev:https": "check-node-version --package", "predev:https": "check-node-version --package",
"derivation-checker": "derivation-checker": "webpack --config=./webpack_config/webpack.derivation-checker.js && node ./dist/derivation-checker.js",
"webpack --config=./webpack_config/webpack.derivation-checker.js && node ./dist/derivation-checker.js",
"tslint": "tslint --project . --exclude common/vendor/*", "tslint": "tslint --project . --exclude common/vendor/*",
"postinstall": "webpack --config=./webpack_config/webpack.dll.js", "postinstall": "webpack --config=./webpack_config/webpack.dll.js",
"start": "npm run dev", "start": "npm run dev",
"precommit": "lint-staged" "precommit": "lint-staged"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx}": ["prettier --write --single-quote", "git add"] "*.{ts,tsx}": [
"prettier --write --single-quote",
"git add"
]
} }
} }