Last active
August 30, 2018 09:45
-
-
Save ikasat/763f811e02a9025d0126dcef55376b31 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as React from "react"; | |
import * as ReactDOM from "react-dom"; | |
import * as Redux from "redux"; | |
import * as ReactRedux from "react-redux"; | |
import * as ReduxForm from "redux-form"; | |
import * as ReactRouter from "react-router"; | |
import * as ReactRouterDOM from "react-router-dom"; | |
import * as ConnectedReactRouter from "connected-react-router"; | |
import * as TypeScriptFSA from "typescript-fsa"; | |
import * as TypeScriptFSAReducers from "typescript-fsa-reducers"; | |
import * as Recompose from "recompose"; | |
import * as Reselect from "reselect"; | |
import * as History from "history"; | |
// 時刻は moment、時間は moment-duration-format で扱う | |
// compilerOptions (tsconfig.json) の allowSyntheticDefaultImports を true にする必要あり | |
import moment from "moment"; | |
import momentDurationFormat from "moment-duration-format"; | |
momentDurationFormat(moment); | |
// ■ 環境設定 | |
// parcel の--public-url の指定に合わせる | |
const baseURLPath = "/url-timer/"; | |
// ■ ユーティリティ関数 | |
const defaultString = (s: string | null | undefined, defaultS: string = "") => (s == null ? defaultS : s); | |
const isBlank = (s: string | null | undefined) => s == null || s === ""; | |
const timeStringToTimestamp = (s: string): number | undefined => { | |
const m = moment(s, [moment.ISO_8601, "X"]); | |
return m.isValid() ? m.unix() : void 0; | |
}; | |
// ■ State | |
type TimerState = { | |
nowTimestamp: number; | |
targetTimeString?: string; | |
targetTimestamp?: number; | |
}; | |
const initialTimerState: TimerState = { | |
nowTimestamp: moment().unix() | |
}; | |
type State = { | |
timer: TimerState; | |
form: ReduxForm.FormStateMap; | |
router: ConnectedReactRouter.RouterState; | |
}; | |
// ■ Action | |
const actionCreator = TypeScriptFSA.actionCreatorFactory(); | |
const changeNowTimestamp = actionCreator<number>("timer/CHANGE_NOW_TIMESTAMP"); | |
const changeTargetTime = actionCreator<{ string?: string; timestamp?: number }>("timer/CHANGE_TARGET_TIME"); | |
// ■ Reducer | |
const timerReducer = TypeScriptFSAReducers.reducerWithInitialState(initialTimerState) | |
.case(changeNowTimestamp, (timerState, payload) => ({ ...timerState, nowTimestamp: payload })) | |
.case(changeTargetTime, (timerState, { string, timestamp }) => ({ | |
...timerState, | |
targetTimeString: string, | |
targetTimestamp: timestamp | |
})) | |
.build(); | |
const reducer = Redux.combineReducers({ | |
timer: timerReducer, | |
form: ReduxForm.reducer | |
}); | |
// ■ Store | |
const browserHistory = History.createBrowserHistory({ basename: baseURLPath }); | |
// Redux Devtools を使う場合 | |
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || Redux.compose; | |
const store = Redux.createStore( | |
ConnectedReactRouter.connectRouter(browserHistory)(reducer), | |
composeEnhancers(Redux.applyMiddleware(ConnectedReactRouter.routerMiddleware(browserHistory))) | |
); | |
// ■ Props | |
// URL (Path) から取得するパラメータ (react-router) | |
interface PathParams { | |
targetTimeString?: string; | |
} | |
// form から取得するパラメータ (redux-form) | |
interface FormData { | |
targetTimeString?: string; | |
} | |
// redux-form と react-router(-dom) によって注入される Props | |
interface OwnProps extends ReduxForm.InjectedFormProps<FormData, {}>, ReactRouter.RouteComponentProps<PathParams> {} | |
// mapStateToProps が生成する Props | |
interface StateProps { | |
initialValues?: FormData; // redux-form が使う | |
nowTimestamp: number; | |
nowTimeString: string; | |
targetTimestamp?: number; | |
targetTimeString?: string; | |
targetTimeISOString?: string; | |
duration?: number; | |
durationString?: string; | |
} | |
// mapDispatchToProps が生成する Props | |
interface DispatchProps { | |
onSubmit(formData: FormData): void; | |
} | |
// mergeProps(あれば)が生成する Props | |
type Props = OwnProps & StateProps & DispatchProps; | |
// ■ Component | |
const TimerComponent = (props: Props) => { | |
const { | |
handleSubmit, | |
onSubmit, | |
nowTimestamp, | |
nowTimeString, | |
targetTimestamp, | |
targetTimeString, | |
targetTimeISOString, | |
duration, | |
durationString | |
} = props; | |
return ( | |
<div style={{ textAlign: "center" }}> | |
<p> | |
(Now: {nowTimeString}, {nowTimestamp}) | |
</p> | |
{targetTimeString == null ? ( | |
<React.Fragment /> | |
) : ( | |
<React.Fragment> | |
<h1> | |
{targetTimeString} | |
<br /> | |
<span style={{ fontSize: "50%" }}> | |
({targetTimeISOString}, {targetTimestamp}) | |
</span> | |
</h1> | |
<h2> | |
{defaultString(durationString)} ({duration}) | |
</h2> | |
</React.Fragment> | |
)} | |
<form> | |
<ul style={{ listStyleType: "none", padding: "0" }}> | |
<li> | |
<ReduxForm.Field component="input" type="text" name="targetTimeString" /> | |
</li> | |
<li> | |
<button onClick={handleSubmit(onSubmit)}>Create New Timer</button> | |
</li> | |
<li> | |
<ReactRouterDOM.Link to="/">Reset</ReactRouterDOM.Link> | |
</li> | |
</ul> | |
</form> | |
</div> | |
); | |
}; | |
// ■ Container | |
// state からデータを抽出するセレクタ | |
const selectLocation = (state: State) => state.router.location; | |
const selectNowTimestamp = (state: State) => state.timer.nowTimestamp; | |
const selectTargetTimeString = (state: State) => state.timer.targetTimeString; | |
const selectTargetTimestamp = (state: State) => state.timer.targetTimestamp; | |
// state 中の関心のある値が更新された場合にのみ新しい StateProps を生成する (reselect) | |
const reselectProps = Reselect.createSelector( | |
[selectLocation, selectNowTimestamp, selectTargetTimeString, selectTargetTimestamp], | |
(_location, nowTimestamp, targetTimeString, targetTimestamp): StateProps => { | |
const nowTimeString = moment.unix(nowTimestamp).toISOString(true); | |
if (targetTimestamp == null) { | |
return { | |
initialValues: { targetTimeString: moment().toISOString(true) }, | |
nowTimestamp, | |
nowTimeString | |
}; | |
} | |
const targetTimeISOString = moment.unix(targetTimestamp).toISOString(true); | |
const duration = targetTimestamp - nowTimestamp; | |
const durationString = moment.duration(duration * 1000).format("d[d]hh:mm:ss"); | |
return { | |
initialValues: {}, | |
nowTimestamp, | |
nowTimeString, | |
targetTimestamp, | |
targetTimeString, | |
targetTimeISOString, | |
duration, | |
durationString | |
}; | |
} | |
); | |
const mapStateToProps = (state: State) => reselectProps(state); | |
const mapDispatchToProps = (dispatch: Redux.Dispatch): DispatchProps => ({ | |
onSubmit({ targetTimeString: maybeTargetTimeString }: FormData) { | |
const targetTimeString = defaultString(maybeTargetTimeString); | |
dispatch(ConnectedReactRouter.push(encodeURIComponent(targetTimeString))); | |
} | |
}); | |
// setInterval の wrapper | |
const generateTicker = (handler: () => void, interval: number) => { | |
return { | |
timerId: void 0 as number | undefined, | |
counter: 0, | |
start() { | |
if (this.counter === 0) { | |
handler(); | |
this.timerId = setInterval(handler, interval) as any; | |
} | |
++this.counter; | |
}, | |
stop() { | |
--this.counter; | |
if (this.counter === 0) { | |
clearInterval(this.timerId); | |
this.timerId = void 0; | |
} | |
} | |
}; | |
}; | |
// 0.5 s 毎に changeNowTimestamp Action を発行するタイマー | |
const nowTimestampTicker = generateTicker(() => { | |
store.dispatch(changeNowTimestamp(moment().unix())); | |
}, 500); | |
// URL が変更された場合の挙動 | |
const onRoute = Reselect.createSelector([(props: Props) => props.match.params.targetTimeString], targetTimeString => { | |
if (targetTimeString != null) { | |
const decodedTargetTimeString = decodeURIComponent(targetTimeString); | |
store.dispatch( | |
changeTargetTime({ | |
string: decodedTargetTimeString, | |
timestamp: timeStringToTimestamp(decodedTargetTimeString) | |
}) | |
); | |
} else { | |
store.dispatch(changeTargetTime({ string: undefined, timestamp: undefined })); | |
} | |
}); | |
// 本当はいくつかの Component / Container に分割すべき | |
const TimerContainer = Redux.compose<() => JSX.Element>( | |
ReactRedux.connect( | |
mapStateToProps, | |
mapDispatchToProps | |
), | |
ReduxForm.reduxForm({ form: "timerContainer" }), | |
ReactRouterDOM.withRouter, | |
Recompose.lifecycle<Props, State>({ | |
componentWillMount() { | |
onRoute(this.props); | |
nowTimestampTicker.start(); | |
}, | |
componentWillUnmount() { | |
nowTimestampTicker.stop(); | |
}, | |
componentWillReceiveProps(props: Props) { | |
onRoute(props); | |
} | |
}) | |
)(TimerComponent); | |
// ■ Application | |
const App = () => ( | |
<ReactRedux.Provider store={store}> | |
<ConnectedReactRouter.ConnectedRouter history={browserHistory}> | |
<React.Fragment> | |
<ReactRouter.Switch> | |
<ReactRouter.Route path="/" exact={true} component={TimerContainer} /> | |
<ReactRouter.Route path="/:targetTimeString" component={TimerContainer} /> | |
<ReactRouter.Route> | |
<p>not found.</p> | |
</ReactRouter.Route> | |
</ReactRouter.Switch> | |
</React.Fragment> | |
</ConnectedReactRouter.ConnectedRouter> | |
</ReactRedux.Provider> | |
); | |
ReactDOM.render(<App />, document.getElementById("app") as HTMLElement); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<div id="app"></div><script src="cheat_sheet.tsx"></script> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"author": "ikasat", | |
"license": "Apache-2.0", | |
"dependencies": { | |
"connected-react-router": "^4.4.1", | |
"history": "^4.7.2", | |
"moment": "^2.22.2", | |
"moment-duration-format": "^2.2.2", | |
"react": ">=15", | |
"react-dom": "^16.4.2", | |
"react-redux": "^4.4.8 || ^5.0.7", | |
"react-router": "^4.3.1", | |
"react-router-dom": "^4.3.1", | |
"recompose": "^0.28.2", | |
"redux": "^3.6.0 || ^4.0.0", | |
"redux-form": "^7.4.2", | |
"reselect": "^3.0.1", | |
"typescript-fsa": "^3.0.0-beta-2", | |
"typescript-fsa-reducers": "^0.4.5" | |
}, | |
"devDependencies": { | |
"@types/history": "^4.7.0", | |
"@types/moment-duration-format": "^2.2.2", | |
"@types/react": "^16.4.10", | |
"@types/react-dom": "^16.0.7", | |
"@types/react-redux": "^6.0.6", | |
"@types/react-router": "^4.0.30", | |
"@types/react-router-dom": "^4.3.0", | |
"@types/recompose": "^0.26.4", | |
"@types/redux-form": "^7.4.5", | |
"parcel-bundler": "^1.9.7", | |
"typescript": "^3.0.1" | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Show hidden characters
{ | |
"compilerOptions": { | |
"jsx": "react", | |
"lib": ["dom", "es2017"], | |
"moduleResolution": "node", | |
"strict": true, | |
"target": "es5", | |
"allowSyntheticDefaultImports": true | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment