Closed42

日本一わかりやすいReact-Redux入門をやる

ピン留めされたアイテム
HishoHisho

やる時間が無いので終了

HishoHisho

せっかくのGWなので、仕事以外の事もしたいと思うので下記の動画をやってみる

https://www.youtube.com/watch?v=FBMA34gUsgw&list=PLX8Rsrpnn3IWavNOj3n4Vypzwb3q1RXhr&index=2

動画の内容以外にやること

  • git(非公開リポジトリ)
  • すべてTypeScriptで記述("strict": true)

自分のレベル感

  • ほぼ独学
  • JavaScript歴約2年(2019-06~)
  • TypeScript歴1年5ヶ月(2020-01~)
  • React歴1年ぐらい(趣味でたまに触るぐらい)
  • React実務経験無し
  • Redux初めて触る
  • Firebase触るの3回目

動作環境

  • OS: macOS Big Sur11.2.3
  • Node.js: v14.16.1
  • npm: 7.9.0
  • yarn: 1.22.5(今回は使っていないけど)
  • react: 17.0.2
  • react-scripts(create-react-app): 4.0.3

最終目標

  • testを実装してみたい
HishoHisho

実は0時から動画見始めてもう6まで終わっている😂

HishoHisho

6までで動画以外でやったこと

1. npm scriptにserveを追加

npm run allとserveを使いbuild後の結果を見れるようコマンドに追加

https://www.npmjs.com/package/serve
https://www.npmjs.com/package/npm-run-all

package.json
{
  "scripts": {
    "serve": "run-s build _serve",
    "_serve": "serve -s build -p 9000",
  }
}

2. importのaliasを追加

相対パスを書きたくなかったのでaliasを追加
https://www.npmjs.com/package/react-app-rewire-alias

package.json
{
  "scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    "serve": "run-s build _serve",
    "test": "react-app-rewired test",
    "_serve": "serve -s build -p 9000",
    "eject": "react-app-rewired eject"
  }
}
tsconfig.json
{
  "extends": "./tsconfig.paths.json",
}
tsconfig.paths.json
{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@src/*": ["src/*"]
    }
  }
}
config-overrides.js
const path = require('path');

module.exports = function override(config) {
  config.resolve = {
    ...config.resolve,
    alias: { '@src': path.resolve(__dirname, 'src') },
  };

  return config;
};

3. FirebaseのBlazeプランに変更

2020/06/22の仕様変更で、Cloud Functionsを使うためにFirebaseのプロジェクトをBlazeプランに変更する必要があるらしいので下記の記事を見ながらプランを変更
https://yutakami.work/?p=625
一応$1超えるとアラートが来るように設定

HishoHisho

ちなみに具体的なファイルの中身は下記の通り😂
型はノリで付けた!動いてるからヨシ👷

index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Provider} from "react-redux";
import {createStore} from "@src/reducks/store/store";
import App from './App';
import reportWebVitals from './reportWebVitals';

export const store = createStore();

ReactDOM.render(
  // <React.StrictMode>
    <Provider store={store}>
      <App/>
    </Provider>,
  // </React.StrictMode>,
  document.getElementById('root')
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
App.tsx
import React from 'react';
import {useDispatch, useSelector} from "react-redux";
import {RootStateType} from "@src/reducks/type";
import {signInAction} from "@src/reducks/users/actions";

function App() {
  const dispatch = useDispatch();
  const selector = useSelector((state:RootStateType) => state);
  console.log(selector)

  return (
    <div>
      <button type="button" onClick={() => dispatch(signInAction({uid: "ssssss",username: 'test'}))}>
        Sign In
      </button>
    </div>
  );
}

export default App;
src/reducks/store/initialState.ts
import {RootStateType} from "@src/reducks/type";

//初期値
export const initialState: RootStateType = {
  users: {
    isSignedIn: true,
    uid: "",
    username: ""
  }
}
src/reducks/store/store.ts
import {createStore as reduxCreateStore, combineReducers} from "redux";
import {UsersReducer} from "@src/reducks/users/reducers";

export const createStore = () => {
  return reduxCreateStore(
    combineReducers({
      users: UsersReducer
    })
  )
}
src/reducks/users/actions.ts
import {UsersType} from "@src/reducks/type";

//Userのアクションタイプの定数
export const ACTION_TYPE = {
  SIGN_IN: "SIGN_IN",
  SIGN_OUT: "SIGN_OUT"
} as const;


//Userがサインインする時の関数
export const signInAction = (userState: Omit<UsersType['state'], 'isSignedIn'>): UsersType['action'] => {
  return {
    type: ACTION_TYPE.SIGN_IN,
    payload: {
      isSignedIn: true,
      uid: userState.uid,
      username: userState.username
    }
  }
}

//Userがサインアウトする時の関数
export const signOutAction = (): UsersType['action'] => {
  return {
    type: ACTION_TYPE.SIGN_OUT,
    payload: {
      isSignedIn: false,
      uid: "",
      username: ""
    }
  }
}
src/reducks/users/reducers.ts
import {initialState} from "@src/reducks/store/initialState";
import {UsersType} from "@src/reducks/type";

//userのアクションを呼ぶReducer
export const UsersReducer = (
  state = initialState.users,
  action: UsersType["action"]
) => {
  switch (action.type) {
    case "SIGN_IN":
      return {
        ...state,
        ...action.payload
      }
    case "SIGN_OUT":
      return {
        ...state,
        ...action.payload
      }
    default:
      // console.error(`type is ${Object.values(ACTION_TYPE).join(' or ')}`);
      // throw new Error(`type is ${Object.values(ACTION_TYPE).join(' or ')}`);
      return state;
  }
}
src/reducks/type.ts
import {ACTION_TYPE} from "@src/reducks/users/actions";

//storeのstateの型
export type RootStateType = {
  users: UsersType['state']
}

//Usersの型をまとめた型
export type UsersType = {
  state: usersStateType
  action: UsersActionType
}

//Usersのstateの型
type usersStateType = {
  isSignedIn: boolean,
  uid: string,
  username: string
}

//Usersのactionの型
type UsersActionType = {
  type: keyof typeof ACTION_TYPE;
  payload: usersStateType;
}

HishoHisho

7

コメントに

historyのバージョンが5.0.0でpushメソッドを使うと以下のエラーが起きるみたいです。
Uncaught Could not find router reducer in state tree, it must be mounted under "router"
以下のコマンドでhistoryのバージョンを4.10.1でインストールし直してみてください。
npm install --save history@4.10.1

と書いてあって震えながら公式のマイグレーションガイド開いていたけど大丈夫だった(4.10.1だった)
https://github.com/supasate/connected-react-router/blob/master/FAQ.md#how-to-migrate-from-v4-to-v5v6

あれ?何故かhistoryが4.10.1になってるな🤔
インストール時にversion指定したっけ?

src/App.tsx
-import React from 'react';
-import {useDispatch, useSelector} from "react-redux";
-import {RootStateType} from "@src/reducks/type";
-import {signInAction} from "@src/reducks/users/actions";
-
-function App() {
-  const dispatch = useDispatch();
-  const selector = useSelector((state:RootStateType) => state);
-  console.log(selector)
+import React from "react";
+import {Router} from "@src/Router";
 
+export const App: React.VFC = () => {
   return (
-    <div>
-      <button type="button" onClick={() => dispatch(signInAction({uid: "ssssss",username: 'test'}))}>
-        Sign In
-      </button>
-    </div>
-  );
-}
-
-export default App;
+    <main>
+      <Router/>
+    </main>
+  )
+}

src/index.tsx
 import ReactDOM from 'react-dom';
 import {Provider} from "react-redux";
 import {createStore} from "@src/reducks/store/store";
-import App from './App';
+import {App} from './App';
 import reportWebVitals from './reportWebVitals';
+import {createBrowserHistory} from "history";
+import {ConnectedRouter} from "connected-react-router";
 
-export const store = createStore();
+const history = createBrowserHistory();
+export const store = createStore(history);
 
 ReactDOM.render(
   // <React.StrictMode>
     <Provider store={store}>
+      <ConnectedRouter history={history}>
       <App/>
+      </ConnectedRouter>
     </Provider>,
   // </React.StrictMode>,
   document.getElementById('root')

historyの型がわからなかったけどconnectRouterrouterMiddlewareの型がHistory<unknown>なので同じにしてみた

src/reducks/store/store.ts
-import {createStore as reduxCreateStore, combineReducers} from "redux";
+import {createStore as reduxCreateStore, combineReducers, applyMiddleware} from "redux";
+import {connectRouter, routerMiddleware} from 'connected-react-router'
 import {UsersReducer} from "@src/reducks/users/reducers";
+import {History} from "history";
 
-export const createStore = () => {
+export const createStore = (history: History) => {
   return reduxCreateStore(
     combineReducers({
+      router: connectRouter(history),
       users: UsersReducer
-    })
+    }),
+    applyMiddleware(
+      routerMiddleware(history),
+    ),
   )
 }
src/pages/Home.tsx
import React from "react";

export const Home:React.VFC = () => {
  return (
    <>
      this is home
    </>
  )
}
src/pages/Login.tsx
import React from "react";
import {useDispatch, useSelector} from "react-redux";
import {push} from "connected-react-router";

export const Login:React.VFC = () => {
  const dispatch = useDispatch();
  const selector = useSelector(state => state);

  console.log(selector);

  return (
    <>
      <h2>ログイン</h2>
      <button type="button" onClick={() => dispatch(push('/'))}>
        ログインする
      </button>
    </>
  )
}
src/pages/index.ts
export {Login} from "@src/pages/Login";
export {Home} from "@src/pages/Home";
HishoHisho

useSelectorの型補完が出ない!

src/pages/Home.tsx
const selector =  useSelector<RootStateType, RootStateType>(state => state);

ジェネリクスで対応しようと思ったけど毎回書くのめんどくさいので何かいい方法はないか調べたら下記の記事が見つかった😀

https://qiita.com/Takepepe/items/6addcb1b0facb8c6ff1f#ambient-module-宣言で-overload-する

src/pages/Home.tsx
-const selector =  useSelector<RootStateType, RootStateType>(state => state);
+const selector = useSelector(state => state);
src/@types/react-redux.ts
// 参考記事
// https://qiita.com/Takepepe/items/6addcb1b0facb8c6ff1f#ambient-module-%E5%AE%A3%E8%A8%80%E3%81%A7-overload-%E3%81%99%E3%82%8B
import 'react-redux'
import {RootStateType} from "@src/reducks/type";

// ______________________________________________________
//

// react-reduxのDefaultRootStateをRootStateTypeでオーバーライドする
declare module 'react-redux' {
  interface DefaultRootState extends RootStateType {
  }
}
HishoHisho

8の全体コード

src/pages/Home.tsx
import React from "react";
import {getUserId} from "@src/reducks/users/selector";
import {useSelector} from "react-redux";

export const Home: React.VFC = () => {
  const selector = useSelector(state => state);
  const uid = getUserId(selector);

  return (
    <>
      this is home
      <p>{uid}</p>
    </>
  )
}
src/pages/Login.tsx
import React from "react";
import {useDispatch, useSelector} from "react-redux";
import {push} from "connected-react-router";
import {signInAction} from "@src/reducks/users/actions";

export const Login:React.VFC = () => {
  const dispatch = useDispatch();
  const selector = useSelector(state => state);

  console.log(selector);

  return (
    <>
      <h2>ログイン</h2>
      <button type="button" onClick={() => {
        dispatch(signInAction({uid: "00001", username: "test"}))
        dispatch(push('/'))
      }}>
        ログインする
      </button>
    </>
  )
}
src/@types/react-redux.ts
// 参考記事
// https://qiita.com/Takepepe/items/6addcb1b0facb8c6ff1f#ambient-module-%E5%AE%A3%E8%A8%80%E3%81%A7-overload-%E3%81%99%E3%82%8B
import 'react-redux'
import {RootStateType} from "@src/reducks/type";

// ______________________________________________________
//

// react-reduxのDefaultRootStateをRootStateTypeでオーバーライドする
declare module 'react-redux' {
  interface DefaultRootState extends RootStateType {
  }
}
src/reducks/users/selector.ts
import {createSelector} from "reselect";
import {RootStateType} from "@src/reducks/type";

const usersSelector = (state:RootStateType) => state.users;

export const getUserId = createSelector(
  [usersSelector],
  state => state.uid
)
HishoHisho

signInのdispatchの型がわからない

useDispatch()の返り値の型がDispatch<any>(export type React.Dispatch<A>)なのでReactからインポートされた型なのが分かる

HishoHisho

onClick={() => dispatch(signIn())}しても動かない

signInにconsole.logでクリックが走ってるか見た所動いていたので、isSignedInの値も見たら初期値をtrueにしていた😂

src/reducks/store/initialState.ts
import {RootStateType} from "@src/reducks/type";

//初期値
export const initialState: RootStateType = {
  users: {
    isSignedIn: false,
    uid: "",
    username: ""
  }
}
HishoHisho

8全体のコード

src/reducks/store/store.ts
import {createStore as reduxCreateStore, combineReducers, applyMiddleware} from "redux";
import {connectRouter, routerMiddleware} from 'connected-react-router'
import {UsersReducer} from "@src/reducks/users/reducers";
import {History} from "history";
import thunk from "redux-thunk";

export const createStore = (history: History) => {
  return reduxCreateStore(
    combineReducers({
      router: connectRouter(history),
      users: UsersReducer
    }),
    applyMiddleware(
      routerMiddleware(history),
      thunk
    ),
  )
}

githubのjsonの型はとりあえずダミー

src/reducks/users/operations.ts
import React from "react";
import {signInAction} from "@src/reducks/users/actions";
import {push} from "connected-react-router";
import {RootStateType} from "@src/reducks/type";

export const signIn = () => {
  return async (dispatch: React.Dispatch<unknown>, getState: () => RootStateType) => {
    const {users} = getState();
    const isSignedIn = users.isSignedIn;
    if (isSignedIn) return;

    const url = "https://api.github.com/users/hisho";
    const response: {
      login: string
    } = await fetch(url).then(res => res.json()).catch(() => null);

    const username = response.login;

    dispatch(signInAction({
      uid: "00001",
      username
    }));

    dispatch(push('/'));
  }
}
src/reducks/users/selector.ts
import {createSelector} from "reselect";
import {RootStateType} from "@src/reducks/type";

const usersSelector = (state: RootStateType) => state.users;

export const getUserId = createSelector(
  [usersSelector],
  state => state.uid
)

export const getUsername = createSelector(
  [usersSelector],
  state => state.username
)
src/pages/Login.tsx
import React from "react";
import {useDispatch} from "react-redux";
import {signIn} from "@src/reducks/users/operations";

export const Login:React.VFC = () => {
  const dispatch = useDispatch();

  return (
    <>
      <h2>ログイン</h2>
      <button type="button" onClick={() => dispatch(signIn())}>
        ログインする
      </button>
    </>
  )
}
src/pages/Home.tsx
import React from "react";
import {getUserId, getUsername} from "@src/reducks/users/selector";
import {useSelector} from "react-redux";

export const Home: React.VFC = () => {
  const selector = useSelector(state => state);
  const uid = getUserId(selector);
  const username = getUsername(selector);

  return (
    <>
      this is home
      <p>ユーザーID{uid}</p>
      <p>ユーザー名:{username}</p>
    </>
  )
}
HishoHisho

コメントアウトを書くのを忘れていたので簡単なコメントアウトを追記していく

HishoHisho
src/reducks/store/store.ts
import {createStore as reduxCreateStore, combineReducers, applyMiddleware} from "redux";
import {connectRouter, routerMiddleware} from 'connected-react-router'
import {UsersReducer} from "@src/reducks/users/reducers";
import {History} from "history";
import thunk from "redux-thunk";

//ストアを作成する
export const createStore = (history: History) => {
  return reduxCreateStore(
    //管理するステート
    combineReducers({
      router: connectRouter(history),
      users: UsersReducer
    }),
    //ミドルウェアの設定
    applyMiddleware(
      routerMiddleware(history),
      thunk
    ),
  )
}
src/reducks/users/operations.ts
import React from "react";
import {signInAction} from "@src/reducks/users/actions";
import {push} from "connected-react-router";
import {RootStateType} from "@src/reducks/type";

//サインインする時に実行する関数
export const signIn = () => {
  return async (dispatch: React.Dispatch<unknown>, getState: () => RootStateType) => {
    //現在のユーザーの値を取得
    const {users} = getState();

    //現在ユーザーがサインインしているかどうか
    const isSignedIn = users.isSignedIn;

    //サインインしている場合は早期リターン
    if (isSignedIn) return;

    //ダミー
    const url = "https://api.github.com/users/hisho";
    const response: {
      login: string
    } = await fetch(url).then(res => res.json()).catch(() => null);
    const username = response.login;

    //uidとusernameを変更してsignInActionを実行
    //signInAction中でisSignedInをtrueに設定しているためisSignedInは不要
    dispatch(signInAction({
      uid: "00001",
      username
    }));

    //サインイン後にトップページへリダイレクト
    dispatch(push('/'));
  }
}
src/reducks/users/selectors.ts
import {createSelector} from "reselect";
import {RootStateType} from "@src/reducks/type";

const usersSelector = (state: RootStateType) => state.users;

//selectorを受け取り現在のユーザーidを返す関数
export const getUserId = createSelector(
  [usersSelector],
  state => state.uid
)

//selectorを受け取り現在のユーザー名を返す関数
export const getUsername = createSelector(
  [usersSelector],
  state => state.username
)

selector.tsが単数形になっていたためselectors.tsにリネーム

HishoHisho

実践編#2

見終わった!
今からまとめる!

HishoHisho

拡張入れたらシンタックスハイライトは効いた

firestore.rules
rules_version = '2';
service cloud.firestore {
	match /databases/{database}/documents {
		match /users/{usersId} {
			allow read: if request.auth.uid != null;
			allow create;
			allow update: if request.auth.uid == userId;
			allow delete: if request.auth.uid == userId;
		}
	}
}

gitからassetsフォルダを落としてcssを読み込ませた

src/App.tsx
import React from "react";
import {Router} from "@src/Router";
import './assets/reset.css';
import './assets/style.css';

export const App: React.VFC = () => {
  return (
    <main>
      <Router/>
    </main>
  )
}
src/pages/SignIn.tsx
import React, {useCallback, useState, ChangeEvent} from "react";
import {PrimaryButton, TextInput} from "@src/components/UIkit";
import {signIn} from "@src/reducks/users/operations";
import {useDispatch} from "react-redux";

export const SignIn = () => {
  const dispatch = useDispatch()

  //emailのstate
  const [email, setEmail] = useState("");
  //passwordのstate
  const [password, setPassword] = useState("");

  //emailのinputがchangeしたら走るイベント
  const inputEmail = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    //現在のvalueをemailにセットする
    setEmail(event.target.value);
  }, [setEmail]);

  //passwordのinputがchangeしたら走るイベント
  const inputPassword = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    //現在のvalueをpasswordにセットする
    setPassword(event.target.value);
  }, [setPassword]);


  return (
    <div className="c-section-container">
      <h2 className="u-text__headline u-text-center">サインイン</h2>
      <div className="module-spacer--medium" aria-hidden="true"/>
      <TextInput
        label="メールアドレス"
        value={email}
        type="email"
        onChange={inputEmail}
      />
      <TextInput
        label="パスワード"
        value={password}
        type="password"
        onChange={inputPassword}
      />
      <div className="module-spacer--medium" aria-hidden="true"/>
      <div className="center">
        <PrimaryButton label="サインイン" onClick={() => dispatch(signIn(email, password))}/>
      </div>
    </div>
  )
}
src/pages/SignUp.tsx
import React, {useCallback, useState, ChangeEvent} from "react";
import {PrimaryButton, TextInput} from "@src/components/UIkit";
import {signUp} from "@src/reducks/users/operations";
import {useDispatch} from "react-redux";

export const SignUp = () => {
  const dispatch = useDispatch()

  //usernameのstate
  const [username, setUsername] = useState("");
  //emailのstate
  const [email, setEmail] = useState("");
  //passwordのstate
  const [password, setPassword] = useState("");
  //confirmPasswordのstate
  const [confirmPassword, setConfirmPassword] = useState("");

  //usernameのinputがchangeしたら走るイベント
  const inputUsername = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    //現在のvalueをusernameにセットする
    setUsername(event.target.value);
  }, [setUsername]);

  //emailのinputがchangeしたら走るイベント
  const inputEmail = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    //現在のvalueをemailにセットする
    setEmail(event.target.value);
  }, [setEmail]);

  //passwordのinputがchangeしたら走るイベント
  const inputPassword = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    //現在のvalueをpasswordにセットする
    setPassword(event.target.value);
  }, [setPassword]);

  //confirmPasswordのinputがchangeしたら走るイベント
  const inputConfirmPassword = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    //現在のvalueをconfirmPasswordにセットする
    setConfirmPassword(event.target.value);
  }, [setConfirmPassword]);

  return (
    <div className="c-section-container">
      <h2 className="u-text__headline u-text-center">アカウント登録</h2>
      <div className="module-spacer--medium" aria-hidden="true"/>
      <TextInput
        label="ユーザー名"
        value={username}
        onChange={inputUsername}
      />
      <TextInput
        label="メールアドレス"
        value={email}
        type="email"
        onChange={inputEmail}
      />
      <TextInput
        label="パスワード"
        value={password}
        type="password"
        onChange={inputPassword}
      />
      <TextInput
        label="パスワード確認用"
        value={confirmPassword}
        type="password"
        onChange={inputConfirmPassword}
      />
      <div className="module-spacer--medium" aria-hidden="true"/>
      <div className="center">
        <PrimaryButton label="アカウントを登録する" onClick={() => dispatch(signUp(username, email, password, confirmPassword))}/>
      </div>
    </div>
  )
}

Loginは削除した

src/pages/index.ts
export {Home} from "@src/pages/Home";
export {SignUp} from "@src/pages/SignUp";
export {SignIn} from "@src/pages/SignIn";
src/Router.tsx
import React from "react";
import {Route, Switch} from "react-router";
import {Home, SignIn, SignUp} from "@src/pages";

export const Router: React.VFC = () => {
  return (
    <Switch>
      <Route exact path="/signin" component={SignIn}/>
      <Route exact path="/signup" component={SignUp}/>
      <Route exact path="(/)?" component={Home}/>
    </Switch>
  )
}
src/components/UIkit/TextInput.tsx
import React from "react";
import {TextField, TextFieldProps} from "@material-ui/core";

type TextInputPropsType = Omit<TextFieldProps, 'margin' | 'autoComplete'>;

export const TextInput: React.VFC<TextInputPropsType> = (
  {
    fullWidth = true,
    multiline = false,
    required = true,
    rows = 1,
    type = "text",
    ...others
  }) => {
  return (
    <TextField
      autoComplete="off"
      fullWidth={fullWidth}
      multiline={multiline}
      rows={rows}
      required={required}
      type={type}
      margin="dense"
      {...others}
    />
  )
}
src/components/UIkit/PrimaryButton.tsx
import React from "react";
import {Button, ButtonProps} from "@material-ui/core";
import {makeStyles} from "@material-ui/styles";

type PrimaryButtonPropsType = Omit<ButtonProps, 'variant'> & {
  label: string
}

const useStyles = makeStyles({
  "button": {
    backgroundColor: "#4dd0e1",
    color: "#333",
    fontSize: 16,
    height: 48,
    marginBottom: 16,
    width: 256
  }
})

export const PrimaryButton: React.VFC<PrimaryButtonPropsType> = (
  {
    label,
    ...others
  }) => {

  const classes = useStyles();

  return (
    <Button className={classes.button} variant="contained" {...others}>
      {label}
    </Button>
  )
}
src/components/UIkit/index.ts
export {TextInput} from '@src/components/UIkit/TextInput';
export {PrimaryButton} from "@src/components/UIkit/PrimaryButton";

roleを追加した

src/reducks/store/initialState.ts
import {RootStateType} from "@src/reducks/type";

//初期値
export const initialState: RootStateType = {
  users: {
    isSignedIn: false,
    role: "",
    uid: "",
    username: ""
  }
}

これもroleを追加した

src/reducks/type.ts
import {ACTION_TYPE} from "@src/reducks/users/actions";

//storeのstateの型
export type RootStateType = {
  users: UsersType['state']
}

//Usersの型をまとめた型
export type UsersType = {
  state: usersStateType
  action: UsersActionType
}

//Usersのstateの型
type usersStateType = {
  isSignedIn: boolean;
  role: string;
  uid: string;
  username: string;
}

//Usersのactionの型
type UsersActionType = {
  type: keyof typeof ACTION_TYPE;
  payload: usersStateType;
}

これもroleを追加した

src/reducks/users/actions.ts
import {UsersType} from "@src/reducks/type";

//Userのアクションタイプの定数
export const ACTION_TYPE = {
  SIGN_IN: "SIGN_IN",
  SIGN_OUT: "SIGN_OUT"
} as const;


//Userがサインインする時の関数
export const signInAction = (userState: Omit<UsersType['state'], 'isSignedIn'>): UsersType['action'] => {
  return {
    type: ACTION_TYPE.SIGN_IN,
    payload: {
      isSignedIn: true,
      role: userState.role,
      uid: userState.uid,
      username: userState.username
    }
  }
}

//Userがサインアウトする時の関数
export const signOutAction = (): UsersType['action'] => {
  return {
    type: ACTION_TYPE.SIGN_OUT,
    payload: {
      isSignedIn: false,
      role: "",
      uid: "",
      username: ""
    }
  }
}
src/reducks/users/operations.ts
import React from "react";
import {push} from "connected-react-router";
import {auth, db, FirebaseTimestamp} from "@src/firebase";
import {signInAction} from "@src/reducks/users/actions";
import {RootStateType} from "@src/reducks/type";

//サインインする時に実行する関数
export const signIn = (email: string, password: string) => {
  return async (dispatch: React.Dispatch<unknown>) => {
    //バリデーション
    //emailとpasswordが空の場合alertを出す
    if (email === "" || password === '') {
      alert("必須項目が未入力です");
      return false;
    }

    //メアドとパスワードで認証する
    auth.signInWithEmailAndPassword(email, password)
      .then(result => {
        const user = result.user;

        if (user) {
          const uid = user.uid;

          //firebaseのusersからuidを検索してgetする
          db.collection('users').doc(uid).get()
            .then((snapshot) => {
              //snapshotは返った来たユーザーのdata
              //TODO asを無くす
              const data = snapshot.data() as Omit<RootStateType["users"], 'isSignedIn'>;

              //ユーザーの認証情報をセットする
              dispatch(signInAction({
                role: data.role,
                uid: data.uid,
                username: data.username
              }))

              //Homeに遷移させる
              dispatch(push('/'))
            })
        }
      })
  }
}

export const signUp = (username: string, email: string, password: string, confirmPassword: string) => {
  return async (dispatch: React.Dispatch<unknown>) => {
    //バリデーション
    //usernameとemailとpasswordとconfirmPasswordが空の場合alertを出す
    if (username === "" || email === "" || password === '' || confirmPassword === "") {
      alert("必須項目が未入力です");
      return false;
    }

    //passwordとconfirmPasswordが一致しない場合alertを出す
    if (password !== confirmPassword) {
      alert("パスワードが一致しません。");
      return false;
    }

    //emailとpasswordでユーザーを作成する
    return auth.createUserWithEmailAndPassword(email, password)
      .then(result => {
        const user = result.user;

        //アカウント作成が成功していたら処理を続ける
        if (user) {
          const uid = user.uid;
          //現在の時間を設定する
          const timestamp = FirebaseTimestamp.now();

          //ユーザーのデータの雛形に当てはめる
          const userInitialData = {
            created_at: timestamp,
            email,
            role: "customer",
            uid,
            updated_at: timestamp,
            username
          }

          //firebaseのusersのuidが一致すれば保存しhomeに遷移させる
          db.collection('users').doc(uid).set(userInitialData)
            .then(() => {
              dispatch(push('/'));
            })
        }
      })
  }
}

HishoHisho

snapshot.data()にasを使っているのでいつか直したい!

HishoHisho
const Auth = ({children}) => {
  return children
}

これできないのね
必ずReact.Fragmentで加工必要があるっぽい

HishoHisho

実践編#3

src/reducks/users/operations.ts
import React from "react";
import {push} from "connected-react-router";
import {auth, db, FirebaseTimestamp} from "@src/firebase";
import {signInAction, signOutAction} from "@src/reducks/users/actions";
import {RootStateType} from "@src/reducks/type";
import firebase from "firebase/app";

//firebaseのUserとdispatchを受け取りサインインする関数
const setFirebaseUserData = async (user: firebase.User, dispatch: React.Dispatch<unknown>) => {
  const uid = user.uid;
  //firebaseのusersからuidを検索してgetする
  db.collection('users').doc(uid).get()
    .then((snapshot) => {
      //snapshotは返った来たユーザーのdata
      //TODO asを無くす
      const data = snapshot.data() as Omit<RootStateType["users"], 'isSignedIn'>;

      //ユーザーの認証情報をセットする
      dispatch(signInAction({
        role: data.role,
        uid: data.uid,
        username: data.username
      }))
    })
}

//サインインしているかどうか監視し返す関数
export const listenAuthState = () => {
  return async (dispatch: React.Dispatch<unknown>) => {
    return auth.onAuthStateChanged((user) => {
      //サインインしている場合
      if (user) {
        setFirebaseUserData(user, dispatch);
        //ユーザーが存在しない場合
      } else {
        //signinページに遷移させる
        dispatch(push('/signin'))
      }
    })
  }
}

//サインインする時に実行する関数
export const signIn = (email: string, password: string) => {
  return async (dispatch: React.Dispatch<unknown>) => {
    //バリデーション
    //emailとpasswordが空の場合alertを出す
    //TODO バリデーションをちゃんとする
    if (email === "" || password === '') {
      alert("必須項目が未入力です");
      return false;
    }

    //メアドとパスワードで認証する
    auth.signInWithEmailAndPassword(email, password)
      .then(result => {
        const user = result.user;
        //サインインしている場合
        if (user) {
          setFirebaseUserData(user, dispatch);
          //Homeに遷移させる
          dispatch(push('/'));
        }
      })
  }
}

export const signUp = (username: string, email: string, password: string, confirmPassword: string) => {
  return async (dispatch: React.Dispatch<unknown>) => {
    //バリデーション
    //usernameとemailとpasswordとconfirmPasswordが空の場合alertを出す
    //TODO バリデーションをちゃんとする
    if (username === "" || email === "" || password === '' || confirmPassword === "") {
      alert("必須項目が未入力です");
      return false;
    }

    //passwordとconfirmPasswordが一致しない場合alertを出す
    //TODO バリデーションをちゃんとする
    if (password !== confirmPassword) {
      alert("パスワードが一致しません。");
      return false;
    }

    //emailとpasswordでユーザーを作成する
    return auth.createUserWithEmailAndPassword(email, password)
      .then(result => {
        const user = result.user;

        //アカウント作成が成功していたら処理を続ける
        if (user) {
          const uid = user.uid;
          //現在の時間を設定する
          const timestamp = FirebaseTimestamp.now();

          //ユーザーのデータの雛形に当てはめる
          const userInitialData = {
            created_at: timestamp,
            email,
            role: "customer",
            uid,
            updated_at: timestamp,
            username
          }

          //firebaseのusersのuidが一致すれば保存しhomeに遷移させる
          db.collection('users').doc(uid).set(userInitialData)
            .then(() => {
              dispatch(push('/'));
            })
        }
      })
  }
}

//サインアウトする時に実行する関数
export const signOut = () => {
  return async (dispatch: React.Dispatch<unknown>) => {
    auth.signOut()
      .then(() => {
        dispatch(signOutAction());
        dispatch(push('/signin'))
      })
  }
}

//パスワードをリセットする時に実行する関数
export const resetPassword = (email: string) => {
  return async (dispatch: React.Dispatch<unknown>) => {
    //メールアドレスに入力がない場合
    if (email === '') {
      alert('メールアドレスが空です。');
      return false;
    } else {
      return auth.sendPasswordResetEmail(email)
        .then(() => {
          alert('パスワードのリセットメールを送信しました。');
          dispatch(push('/signin'));
        }).catch(() => {
          alert('パスワードリセットに失敗しました。');
        })
    }
  }
}
src/reducks/users/selectors.ts
import {createSelector} from "reselect";
import {RootStateType} from "@src/reducks/type";

const usersSelector = (state: RootStateType) => state.users;

//selectorを受け取り現在ユーザーがサインインしているかどうがを返す関数
export const getIsSignedIn = createSelector(
  [usersSelector],
  state => state.isSignedIn
)

//selectorを受け取り現在のユーザーidを返す関数
export const getUserId = createSelector(
  [usersSelector],
  state => state.uid
)

//selectorを受け取り現在のユーザー名を返す関数
export const getUsername = createSelector(
  [usersSelector],
  state => state.username
)
src/pages/Home.tsx
import React from "react";
import {getUserId, getUsername} from "@src/reducks/users/selectors";
import {useDispatch, useSelector} from "react-redux";
import {signOut} from "@src/reducks/users/operations";

export const Home: React.VFC = () => {
  const dispatch = useDispatch();
  const selector = useSelector(state => state);
  const uid = getUserId(selector);
  const username = getUsername(selector);

  return (
    <>
      this is home
      <p>ユーザーID{uid}</p>
      <p>ユーザー名:{username}</p>
      <button type="button" onClick={() => dispatch(signOut())}>サインアウト</button>
    </>
  )
}
src/pages/Reset.tsx
import React, {useCallback, useState, ChangeEvent} from "react";
import {PrimaryButton, TextInput} from "@src/components/UIkit";
import {resetPassword} from "@src/reducks/users/operations";
import {useDispatch} from "react-redux";
import {push} from "connected-react-router";

export const Reset = () => {
  const dispatch = useDispatch()

  //emailのstate
  const [email, setEmail] = useState("");

  //emailのinputがchangeしたら走るイベント
  const inputEmail = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    //現在のvalueをemailにセットする
    setEmail(event.target.value);
  }, [setEmail]);

  return (
    <div className="c-section-container">
      <h2 className="u-text__headline u-text-center">リセット</h2>
      <div className="module-spacer--medium" aria-hidden="true"/>
      <TextInput
        label="メールアドレス"
        value={email}
        type="email"
        onChange={inputEmail}
      />
      <div className="module-spacer--medium" aria-hidden="true"/>
      <div className="center">
        <PrimaryButton label="Reset Password" onClick={() => dispatch(resetPassword(email))}/>
        <div className="module-spacer--medium" aria-hidden="true"/>
        <button type="button" onClick={() => dispatch(push('/signin'))}>アカウントをお持ちの方はこちら</button>
        <button type="button" onClick={() => dispatch(push('/signup'))}>アカウントをお持ちではない方はこちら</button>
      </div>
    </div>
  )
}
src/pages/SignIn.tsx
import React, {useCallback, useState, ChangeEvent} from "react";
import {PrimaryButton, TextInput} from "@src/components/UIkit";
import {signIn} from "@src/reducks/users/operations";
import {useDispatch} from "react-redux";
import {push} from "connected-react-router";

export const SignIn = () => {
  const dispatch = useDispatch()

  //emailのstate
  const [email, setEmail] = useState("");
  //passwordのstate
  const [password, setPassword] = useState("");

  //emailのinputがchangeしたら走るイベント
  const inputEmail = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    //現在のvalueをemailにセットする
    setEmail(event.target.value);
  }, [setEmail]);

  //passwordのinputがchangeしたら走るイベント
  const inputPassword = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    //現在のvalueをpasswordにセットする
    setPassword(event.target.value);
  }, [setPassword]);


  return (
    <div className="c-section-container">
      <h2 className="u-text__headline u-text-center">サインイン</h2>
      <div className="module-spacer--medium" aria-hidden="true"/>
      <TextInput
        label="メールアドレス"
        value={email}
        type="email"
        onChange={inputEmail}
      />
      <TextInput
        label="パスワード"
        value={password}
        type="password"
        onChange={inputPassword}
      />
      <div className="module-spacer--medium" aria-hidden="true"/>
      <div className="center">
        <PrimaryButton label="サインイン" onClick={() => dispatch(signIn(email, password))}/>
        <div className="module-spacer--medium" aria-hidden="true"/>
        <button type="button" onClick={() => dispatch(push('/signup'))}>アカウントをお持ちでない方はこちら</button>
        <button type="button" onClick={() => dispatch(push('/signin/reset'))}>パスワードを忘れた方はこちら</button>
      </div>
    </div>
  )
}
src/pages/SignUp.tsx
import React, {useCallback, useState, ChangeEvent} from "react";
import {PrimaryButton, TextInput} from "@src/components/UIkit";
import {signUp} from "@src/reducks/users/operations";
import {useDispatch} from "react-redux";
import {push} from "connected-react-router";

export const SignUp = () => {
  const dispatch = useDispatch()

  //usernameのstate
  const [username, setUsername] = useState("");
  //emailのstate
  const [email, setEmail] = useState("");
  //passwordのstate
  const [password, setPassword] = useState("");
  //confirmPasswordのstate
  const [confirmPassword, setConfirmPassword] = useState("");

  //usernameのinputがchangeしたら走るイベント
  const inputUsername = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    //現在のvalueをusernameにセットする
    setUsername(event.target.value);
  }, [setUsername]);

  //emailのinputがchangeしたら走るイベント
  const inputEmail = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    //現在のvalueをemailにセットする
    setEmail(event.target.value);
  }, [setEmail]);

  //passwordのinputがchangeしたら走るイベント
  const inputPassword = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    //現在のvalueをpasswordにセットする
    setPassword(event.target.value);
  }, [setPassword]);

  //confirmPasswordのinputがchangeしたら走るイベント
  const inputConfirmPassword = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    //現在のvalueをconfirmPasswordにセットする
    setConfirmPassword(event.target.value);
  }, [setConfirmPassword]);

  return (
    <div className="c-section-container">
      <h2 className="u-text__headline u-text-center">アカウント登録</h2>
      <div className="module-spacer--medium" aria-hidden="true"/>
      <TextInput
        label="ユーザー名"
        value={username}
        onChange={inputUsername}
      />
      <TextInput
        label="メールアドレス"
        value={email}
        type="email"
        onChange={inputEmail}
      />
      <TextInput
        label="パスワード"
        value={password}
        type="password"
        onChange={inputPassword}
      />
      <TextInput
        label="パスワード確認用"
        value={confirmPassword}
        type="password"
        onChange={inputConfirmPassword}
      />
      <div className="module-spacer--medium" aria-hidden="true"/>
      <div className="center">
        <PrimaryButton label="アカウントを登録する" onClick={() => dispatch(signUp(username, email, password, confirmPassword))}/>
        <div className="module-spacer--medium" aria-hidden="true"/>
        <button type="button" onClick={() => dispatch(push('/signin'))}>アカウントをお持ちの方はこちら</button>
        <button type="button" onClick={() => dispatch(push('/signin/reset'))}>パスワードを忘れた方はこちら</button>
      </div>
    </div>
  )
}
src/pages/index.ts
export {Home} from "@src/pages/Home";
export {SignUp} from "@src/pages/SignUp";
export {SignIn} from "@src/pages/SignIn";
export {Reset} from "@src/pages/Reset";
src/Auth.tsx
import React, {useEffect} from "react";
import {useDispatch, useSelector} from "react-redux";
import {getIsSignedIn} from "@src/reducks/users/selectors";
import {listenAuthState} from "@src/reducks/users/operations";

type AuthPropsType = {
  children: React.ReactNode
}

export const Auth: React.VFC<AuthPropsType> = ({children}) => {
  const dispatch = useDispatch();
  const selector = useSelector(state => state);
  const isSignedIn = getIsSignedIn(selector);

  useEffect(() => {
    if (!isSignedIn) {
      dispatch(listenAuthState());
    }
  }, [])

  return <>{isSignedIn && children}</>
}
src/Router.tsx
import React from "react";
import {Route, Switch} from "react-router";
import {Home, SignIn, SignUp, Reset} from "@src/pages";
import {Auth} from "@src/Auth";

export const Router: React.VFC = () => {
  return (
    <Switch>
      <Route exact path="/signin" component={SignIn}/>
      <Route exact path="/signin/reset" component={Reset}/>
      <Route exact path="/signup" component={SignUp}/>
      <Auth>
        <Route exact path="(/)?" component={Home}/>
      </Auth>
    </Switch>
  )
}
HishoHisho

実践編#4

とりあえず、アクションはダミー

src/reducks/store/initialState.ts
import {RootStateType} from "@src/reducks/type";

//初期値
export const initialState: RootStateType = {
  products: {
    list: []
  },
  users: {
    isSignedIn: false,
    role: "",
    uid: "",
    username: ""
  }
}

Redux loggerを追加

src/reducks/store/store.ts
import {createStore as reduxCreateStore, combineReducers, applyMiddleware} from "redux";
import {connectRouter, routerMiddleware} from 'connected-react-router'
import {UsersReducer} from "@src/reducks/users/reducers";
import {ProductsReducer} from "@src/reducks/products/reducers";
import {History} from "history";
import thunk from "redux-thunk";
import {createLogger} from "redux-logger";

//ストアを作成する
export const createStore = (history: History) => {
  const logger = createLogger({
    collapsed: true,
    diff: true
  })

  return reduxCreateStore(
    //管理するステート
    combineReducers({
      router: connectRouter(history),
      users: UsersReducer,
      products: ProductsReducer
    }),
    //ミドルウェアの設定
    applyMiddleware(
      logger,
      routerMiddleware(history),
      thunk
    ),
  )
}
src/reducks/products/actions.ts
//Productsのアクションタイプの定数
export const ACTION_TYPE = {
  SIGN_IN: "SIGN_IN",
} as const;
src/firebase/index.ts
import firebase from "firebase/app";
import 'firebase/auth';
import 'firebase/firestore';
import 'firebase/storage';
import 'firebase/functions';
import {firebaseConfig} from "@src/firebase/config";

//firebaseの初期化
firebase.initializeApp(firebaseConfig);

//firebase関連を予めexportしておく
export const auth = firebase.auth();
export const db = firebase.firestore();
export const storage = firebase.storage();
export const functions = firebase.functions();
export const FirebaseTimestamp = firebase.firestore.Timestamp;
export type FirebaseTimestampType = firebase.firestore.Timestamp;

productDataTypeがしょぼいので今後書き直す

src/reducks/products/operations.ts
import React from "react";
import {db, FirebaseTimestamp, FirebaseTimestampType} from "@src/firebase";
import {push} from "connected-react-router";

//firebaseのdbのproductsを一旦変数に代入
const productsRef = db.collection('products');

//TODO idとcreate_atは必須なのでpartialを無くす
//productのdataの型
type productDataType = {
  id?: string;
  name: string;
  description: string;
  category: string;
  gender: string;
  price: number;
  update_at: FirebaseTimestampType;
  created_at?: FirebaseTimestampType;
}

//商品を追加、編集する時に実行される関数
export const saveProducts = (name: string, description: string, category: string, gender: string, price: string) => {
  return async (dispatch: React.Dispatch<unknown>) => {
    //現在の時刻
    const timestamp = FirebaseTimestamp.now();

    //productのdata(更新用)
    const data: productDataType = {
      name,
      description,
      category,
      gender,
      price: parseInt(price, 10),
      update_at: timestamp,
    }

    //firebaseが自動生成するidを取得する
    const {id} = productsRef.doc();

    //productのidにfirebaseが自動生成したidをセットする
    data.id = id;
    //productのcreate_atに作った時刻をセットする
    data.created_at = timestamp;

    //firebaseのproductのidに上記のdataをセットする
    return productsRef.doc(id).set(data)
      .then(() => {
        dispatch(push('/'))
      }).catch((e) => {
        throw new Error(e);
      })
  }
}

気がついたけど、エラーはいらないかも😂

src/reducks/products/reducers.ts
import {initialState} from "@src/reducks/store/initialState";
import {ProductsType} from "@src/reducks/type";

//productsのアクションを呼ぶReducer
export const ProductsReducer = (
  state = initialState.users,
  action: ProductsType["action"]
) => {
  switch (action.type) {
    default:
      // console.error(`type is ${Object.values(ACTION_TYPE).join(' or ')}`);
      // throw new Error(`type is ${Object.values(ACTION_TYPE).join(' or ')}`);
      return state;
  }
}
src/reducks/products/selectors.ts
import {createSelector} from "reselect";
import {RootStateType} from "@src/reducks/type";

const productsSelector = (state: RootStateType) => state.products;

//selectorを受け取り現在プロダクトのリストを返す関数
export const getList = createSelector(
  [productsSelector],
  state => state.list
)

productsが増えるのでACTION_TYPEに名前空間を追加

src/reducks/type.ts
import * as Users from "@src/reducks/users/actions";

//storeのstateの型
export type RootStateType = {
  products: ProductsType["state"]
  users: UsersType['state']
}

//Productsの型をまとめた型
export type ProductsType = {
  state: ProductsStateType
  action: ProductsActionType
}

//Usersの型をまとめた型
export type UsersType = {
  state: UsersStateType
  action: UsersActionType
}

//Productsのstateの型
type ProductsStateType = {
  list: string[]
}


//Productsのactionの型
type ProductsActionType = {
  //TODO Productsのアクションを作る
  type: keyof typeof Users.ACTION_TYPE;
  payload: UsersStateType;
}


//Usersのstateの型
type UsersStateType = {
  isSignedIn: boolean;
  role: string;
  uid: string;
  username: string;
}

//Usersのactionの型
type UsersActionType = {
  type: keyof typeof Users.ACTION_TYPE;
  payload: UsersStateType;
}

material-uiのSelectにeventの実体がselecetのChangeEventでは無いらしいのでevent.target.value as stringにはasを使用
コメントはこの投稿終わったら書く

src/components/UIkit/SelectBox.tsx
import React from "react";
import {InputLabel, MenuItem, FormControl, Select, makeStyles} from "@material-ui/core";


type SelectBoxPropsType = {
  label: string;
  required?: boolean;
  value: string;
  select: (value: string) => void;
  options: {
    id: string
    name: string
  }[]
}

const useStyles = makeStyles({
  formControl: {
    marginBottom: 16,
    minWidth: 128,
    width: '100%'
  }
})

export const SelectBox: React.VFC<SelectBoxPropsType> = (
  {
    label,
    required = true,
    value,
    select,
    options
  }) => {

  const classes = useStyles();

  return (
    <FormControl className={classes.formControl}>
      <InputLabel>{label}</InputLabel>
      <Select required={required} value={value} onChange={(event) => select(event.target.value as string)}>
        {options.map((option) => (
          <MenuItem key={option.id} value={option.id}>
            {option.name}
          </MenuItem>
        ))}
      </Select>
    </FormControl>
  )
}
src/components/UIkit/index.ts
export {TextInput} from '@src/components/UIkit/TextInput';
export {PrimaryButton} from "@src/components/UIkit/PrimaryButton";
export {SelectBox} from "@src/components/UIkit/SelectBox";
src/Router.tsx
import React from "react";
import {Route, Switch} from "react-router";
import {Home, SignIn, SignUp, Reset,ProductEdit} from "@src/pages";
import {Auth} from "@src/Auth";

export const Router: React.VFC = () => {
  return (
    <Switch>
      <Route exact path="/signin" component={SignIn}/>
      <Route exact path="/signin/reset" component={Reset}/>
      <Route exact path="/signup" component={SignUp}/>
      <Auth>
        <Route exact path="(/)?" component={Home}/>
        <Route exact path="(/product/edit)" component={ProductEdit}/>
      </Auth>
    </Switch>
  )
}
firestore.rules
rules_version = '2';
service cloud.firestore {
	match /databases/{database}/documents {
		match /users/{userId} {
			allow read: if request.auth.uid != null;
			allow create;
			allow update: if request.auth.uid == userId;
			allow delete: if request.auth.uid == userId;
		}
		match /products/{productId} {
			allow read: if request.auth.uid != null;
			allow write: if request.auth.uid != null;
		}
	}
}

カテゴリーとジェンダーは外部切り出ししても良いかも

src/pages/ProductEdit.tsx
import React, {ChangeEvent, useCallback, useState} from "react";
import {PrimaryButton, SelectBox, TextInput} from "@src/components/UIkit";
import {useDispatch} from "react-redux";
import {saveProducts} from "@src/reducks/products/operations";

const categories = [
  {
    id: "tops",
    name: "トップス"
  },
  {
    id: "shirt",
    name: "シャツ"
  },
  {
    id: "pants",
    name: "パンツ"
  }
];

const genders = [
  {
    id: "all",
    name: "すべて"
  },
  {
    id: "male",
    name: "男性"
  },
  {
    id: "female",
    name: "女性"
  }
];

export const ProductEdit: React.VFC = () => {
  const dispatch = useDispatch();
  const [name, setName] = useState('');
  const [description, setDescription] = useState('');
  const [category, setCategory] = useState('');
  const [gender, setGender] = useState('');
  const [price, setPrice] = useState('');

  const inputName = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    setName(event.target.value);
  }, [setName]);

  const inputDescription = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => {
    setDescription(event.target.value);
  }, [setDescription]);

  const inputPrice = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    setPrice(event.target.value);
  }, [setPrice]);

  return (
    <section>
      <h2 className="u-text__headline u-text-center">商品の登録・編集</h2>
      <div className="c-section-container">
        <TextInput label="商品名" value={name} onChange={inputName}/>
        <TextInput label="商品説明" value={description} onChange={inputDescription} multiline={true} rows={5}/>
        <SelectBox label="カテゴリー" select={setCategory} value={category} options={categories} />
        <SelectBox label="性別" select={setGender} value={gender} options={genders} />
        <TextInput label="価格" value={price} onChange={inputPrice} type="number"/>
        <div className="module-spacer--medium"/>
        <div className="center">
          <PrimaryButton label="商品情報を保存" onClick={() => dispatch(saveProducts(name,description,category,gender,price))} />
        </div>
      </div>
    </section>
  )
}
src/pages/index.ts
export {Home} from "@src/pages/Home";
export {SignUp} from "@src/pages/SignUp";
export {SignIn} from "@src/pages/SignIn";
export {Reset} from "@src/pages/Reset";
export {ProductEdit} from "@src/pages/ProductEdit";
HishoHisho

カテゴリーと性別はstringではないので切り出して型を変更する

HishoHisho

categoriesとgendersを切り出してas constするとSelectBoxPropsTypeのoptionsの型をTにしないといけないけど、idとnameを確実に持っているって型の書き方がわからないので甘えたstringで続けます😂

HishoHisho

実践編#5

src/components/products/ImageArea.tsx
import React, {useCallback} from "react";
import {IconButton} from "@material-ui/core";
import {AddPhotoAlternate} from "@material-ui/icons";
import {makeStyles} from '@material-ui/styles';
import {storage} from "@src/firebase";
import {imageType} from "@src/components/products/types";
import {ImagePreview} from "@src/components/products/ImagePreview";

type ImageAreaPropsType = {
  images: imageType[];
  setImages: React.Dispatch<React.SetStateAction<imageType[]>>
}

const useStyles = makeStyles({
  icon: {
    height: 48,
    width: 48
  }
})

function randomString(length: number = 16): string {
  const strings = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
  return Array.from(crypto.getRandomValues(new Uint8Array(length))).map((n) => {
    return strings[n % strings.length];
  }).join('');
}

export const ImageArea: React.VFC<ImageAreaPropsType> = (
  {
    images,
    setImages
  }) => {
  const classes = useStyles();

  const uploadImages = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files as unknown as BlobPart[];
    const blob = new Blob(file, {type: 'image/png'});

    const fileName = randomString();

    const uploadRef = storage.ref('images').child(fileName);
    const uploadTask = uploadRef.put(blob);

    uploadTask.then(() => {
      uploadTask.snapshot.ref.getDownloadURL()
        .then((downloadURL) => {

          if (!(typeof downloadURL === 'string')) return;

          const newImage = {
            id: fileName,
            path: downloadURL
          };

          setImages((prevState) => ([...prevState, newImage]));
        })
    })
  }, [setImages])

  const deleteImages = useCallback(async (id: imageType['id']) => {
    const ret = window.confirm('この画像を削除しますか?');

    if (!ret) return false;

    const newImages = images.filter((image) => image.id !== id);
    setImages(newImages);
    return storage.ref('images').child(id).delete();

  }, [images]);

  return (
    <div>
      <div className="p-grid__list-images">
        {images.length > 0 && (
          images.map((image) => (
            <ImagePreview onDelete={deleteImages} image={image} key={image.id}/>
          ))
        )}
      </div>
      <div className="u-text-right">
        <span>商品を登録する</span>
        <IconButton className={classes.icon}>
          <label>
            <AddPhotoAlternate/>
            <input className="u-display-none" type="file" id="image" onChange={(event) => uploadImages(event)}/>
          </label>
        </IconButton>
      </div>
    </div>
  )
}
src/components/products/ImagePreview.tsx
import React from "react";
import {imageType} from "@src/components/products/types";

type ImagePreviewPropsType = {
  onDelete: (id: imageType["id"]) => Promise<unknown>;
  image: imageType
}

export const ImagePreview:React.VFC<ImagePreviewPropsType> = (
  {
    onDelete,
    image
  }) => {
  return (
    <button type="button" className="p-media__thumb" onClick={() => onDelete(image.id)}>
      <img src={image.path} alt=""/>
    </button>
  )
}
src/components/products/types.ts
export type imageType = {
  id: string;
  path: string;
};
src/pages/ProductEdit.tsx
import React, {ChangeEvent, useCallback, useState} from "react";
import {PrimaryButton, SelectBox, TextInput} from "@src/components/UIkit";
import {useDispatch} from "react-redux";
import {saveProducts} from "@src/reducks/products/operations";
import {categories,genders} from "@src/reducks/products/type";
import {ImageArea} from "@src/components/products/ImageArea";
import {imageType} from "@src/components/products/types";

//商品を追加・編集するページ
export const ProductEdit: React.VFC = () => {
  const dispatch = useDispatch();
  //画像を管理するstate
  const [images, setImages] = useState<imageType[]>([]);

  //商品名を管理するstate
  const [name, setName] = useState('');

  //商品の説明を管理するstate
  const [description, setDescription] = useState('');

  //商品のカテゴリーを管理するstate
  const [category, setCategory] = useState('');

  //商品の性別を管理するstate
  const [gender, setGender] = useState('');

  //商品の価格を管理するstate
  const [price, setPrice] = useState('');

  //nameのinputがchangeしたら走るイベント
  const inputName = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    setName(event.target.value);
  }, [setName]);

  //descriptionのinputがchangeしたら走るイベント
  const inputDescription = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => {
    setDescription(event.target.value);
  }, [setDescription]);

  //priceのinputがchangeしたら走るイベント
  const inputPrice = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    setPrice(event.target.value);
  }, [setPrice]);

  return (
    <section>
      <h2 className="u-text__headline u-text-center">商品の登録・編集</h2>
      <div className="c-section-container">
        <ImageArea images={images} setImages={setImages} />
        <TextInput label="商品名" value={name} onChange={inputName}/>
        <TextInput label="商品説明" value={description} onChange={inputDescription} multiline={true} rows={5}/>
        <SelectBox label="カテゴリー" select={setCategory} value={category} options={categories} />
        <SelectBox label="性別" select={setGender} value={gender} options={genders} />
        <TextInput label="価格" value={price} onChange={inputPrice} type="number"/>
        <div className="module-spacer--medium"/>
        <div className="center">
          <PrimaryButton label="商品情報を保存" onClick={() => dispatch(saveProducts(images,name,description,category,gender,price))} />
        </div>
      </div>
    </section>
  )
}
src/reducks/products/operations.ts
import React from "react";
import {db, FirebaseTimestamp, FirebaseTimestampType} from "@src/firebase";
import {push} from "connected-react-router";
import {imageType} from "@src/components/products/types";

//firebaseのdbのproductsを一旦変数に代入
const productsRef = db.collection('products');

//TODO idとcreate_atは必須なのでpartialを無くす
//productのdataの型
type productDataType = {
  images: imageType[];
  id?: string;
  name: string;
  description: string;
  category: string;
  gender: string;
  price: number;
  update_at: FirebaseTimestampType;
  created_at?: FirebaseTimestampType;
}

//商品を追加、編集する時に実行される関数
export const saveProducts = (images: imageType[], name: string, description: string, category: string, gender: string, price: string) => {
  return async (dispatch: React.Dispatch<unknown>) => {
    //現在の時刻
    const timestamp = FirebaseTimestamp.now();

    //productのdata(更新用)
    const data: productDataType = {
      images,
      name,
      description,
      category,
      gender,
      price: parseInt(price, 10),
      update_at: timestamp,
    }

    //firebaseが自動生成するidを取得する
    const {id} = productsRef.doc();

    //productのidにfirebaseが自動生成したidをセットする
    data.id = id;
    //productのcreate_atに作った時刻をセットする
    data.created_at = timestamp;

    //firebaseのproductのidに上記のdataをセットする
    return productsRef.doc(id).set(data)
      .then(() => {
        dispatch(push('/'))
      }).catch((e) => {
        throw new Error(e);
      })
  }
}
HishoHisho
const file = event.target.files as unknown as BlobPart[];
const blob = new Blob(file, {type: 'image/png'});

この部分無理矢理キャストしたんだけど、正攻法がわからない😥

HishoHisho

new Blobの第一引数がBlobPart[]なんだけど、event.target.filesの返り値がFileListでそのまま代入しようとすると

Argument of type 'FileList' is not assignable to parameter of type 'BlobPart[]'.   Type 'FileList' is missing the following properties from type 'BlobPart[]': pop, push, concat, join, and 26 more

って怒られますね😂

https://teratail.com/questions/291281

new Blobは不要とあるのでなしにしてみてもだめっぽい😂

まぁまた今度リファクタリングする時にまた調べよう

HishoHisho
const file = event.target.files;
if(!file) return;
const blob = new Blob(file as unknown as BlobPart[], {type: 'image/jpeg'});

こっちの方が安全かな?
一応null対策もしとこう

HishoHisho

コメント追記してまた夜か明日続きやろう

HishoHisho

実践編#6

src/pages/ProductEdit.tsx
import React, {ChangeEvent, useCallback, useEffect, useState} from "react";
import {PrimaryButton, SelectBox, TextInput} from "@src/components/UIkit";
import {useDispatch} from "react-redux";
import {productDataType, saveProducts} from "@src/reducks/products/operations";
import {categories, genders} from "@src/reducks/products/type";
import {ImageArea} from "@src/components/products/ImageArea";
import {imageType, sizeType} from "@src/components/products/types";
import {db} from "@src/firebase";
import {SetSizeArea} from "@src/components/products";

//商品を追加・編集するページ
export const ProductEdit: React.VFC = () => {
  const dispatch = useDispatch();

  /**
   * URLから商品情報のIDを取得する関数
   * /product/edit/商品ID
   * から/product/edit/を削除する
   * /product/edit/ページの場合は ''
   * その他は商品IDが入る
   */
  const id = window.location.pathname.replace(/\/product\/edit(\/)?/, '');

  //画像を管理するstate
  const [images, setImages] = useState<imageType[]>([]);

  //商品名を管理するstate
  const [name, setName] = useState('');

  //商品の説明を管理するstate
  const [description, setDescription] = useState('');

  //商品のカテゴリーを管理するstate
  const [category, setCategory] = useState('');

  //商品の性別を管理するstate
  const [gender, setGender] = useState('');

  //商品の価格を管理するstate
  const [price, setPrice] = useState('');

  const [sizes, setSizes] = useState<sizeType[]>([]);

  //nameのinputがchangeしたら走るイベント
  const inputName = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    setName(event.target.value);
  }, [setName]);

  //descriptionのinputがchangeしたら走るイベント
  const inputDescription = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => {
    setDescription(event.target.value);
  }, [setDescription]);

  //priceのinputがchangeしたら走るイベント
  const inputPrice = useCallback((event: ChangeEvent<HTMLInputElement>) => {
    setPrice(event.target.value);
  }, [setPrice]);

  useEffect(() => {
    //idが空の場合は早期リターン
    if (!id) return;

    //現在のfirebaseからidのproductsを取得する
    db.collection('products').doc(id).get()
      .then((snapshot) => {
        //TODO 型キャストをやめる
        const data = snapshot.data() as productDataType;
        //data.nameをnameに代入する
        setName(data.name);

        //data.descriptionをdescriptionに代入する
        setDescription(data.description);

        //data.categoryをcategoryに代入する
        setCategory(data.category);

        //data.genderをgenderに代入する
        setGender(data.gender);

        //data.priceをpriceに代入する
        setPrice(String(data.price));

        //data.priceをpriceに代入する
        setPrice(String(data.price));

        //data.sizesをsizesに代入する
        setSizes(data.sizes);
      })
  }, [id]);

  return (
    <section>
      <h2 className="u-text__headline u-text-center">商品の登録・編集</h2>
      <div className="c-section-container">
        <ImageArea images={images} setImages={setImages}/>
        <TextInput label="商品名" value={name} onChange={inputName}/>
        <TextInput label="商品説明" value={description} onChange={inputDescription} multiline={true} rows={5}/>
        <SelectBox label="カテゴリー" select={setCategory} value={category} options={categories}/>
        <SelectBox label="性別" select={setGender} value={gender} options={genders}/>
        <TextInput label="価格" value={price} onChange={inputPrice} type="number"/>
        <div className="module-spacer--small"/>
        <SetSizeArea sizes={sizes} setSizes={setSizes}/>
        <div className="module-spacer--medium"/>
        <div className="center">
          <PrimaryButton
            label="商品情報を保存"
            onClick={() => dispatch(saveProducts(id, images, name, description, category, gender, price, sizes))}/>
        </div>
      </div>
    </section>
  )
}
src/components/products/SetSizeArea.tsx
import React, {useCallback, useMemo, useState} from "react";
import {
  TableContainer,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableRow,
  IconButton,
  makeStyles, Paper
} from "@material-ui/core";
import {CheckCircle, Delete, Edit} from "@material-ui/icons";
import {TextInput} from "@src/components/UIkit";
import {sizeType} from "@src/components/products/types";

const useStyles = makeStyles({
  checkIcon: {
    float: 'right'
  },
  iconCell: {
    height: 48,
    width: 48
  }
})

type changeSize = (index: number, {size, quantity}: sizeType) => void;

type SetSizeAreaPropsType = {
  sizes: sizeType[];
  setSizes: React.Dispatch<React.SetStateAction<sizeType[]>>
}

export const SetSizeArea: React.VFC<SetSizeAreaPropsType> = (
  {
    sizes,
    setSizes
  }) => {
  const classes = useStyles();

  //商品のサイズと数量のセット数を管理するstate
  const [index, setIndex] = useState(0);

  //サイズを管理するstate
  const [size, setSize] = useState("");

  //数量を管理するstate
  const [quantity, setQuantity] = useState(0);

  //sizeのinputがchangeしたら走るイベント
  const inputSize = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
    setSize(event.target.value);
  }, [setSize]);

  //quantityのinputがchangeしたら走るイベント
  const inputQuantity = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
    if (!event.target.value) return;
    setQuantity(parseInt(event.target.value));
  }, [setQuantity]);

  //商品のサイズと数量のセットを増やす時の関数
  const addSize: changeSize = (index, {size, quantity}) => {
    //sizeかquantityどちらか一つでも空の場合早期リターン
    if (!size || !quantity) return;

    //最新(新規)なら追加する
    if (index === sizes.length) {
      //前の値とマージする
      setSizes((prevState) => [...prevState, {size, quantity}]);
      //indexを+1する
      setIndex((prevState => prevState + 1));

      //既存の場合
    } else {
      //sizeを一旦変数に代入
      const newSizes = sizes;
      //sizeのindex番(選択した既存の番号)のsizeとquantityを上書きする
      newSizes[index] = {size, quantity};
      //setSizesでstateに保存する
      setSizes(newSizes);
      //indexをnewSizes.length(最新の長さ)にする
      setIndex(newSizes.length);
    }
    //共通の設定
    //値を初期化する
    setSize("");
    setQuantity(0);
  }

  //現在選択されている商品のサイズと数量のセットを編集する関数
  const editSize: changeSize = (index, {size, quantity}) => {
    setIndex(index);
    setSize(size);
    setQuantity(quantity);
  }

  //現在選択されている商品のサイズと数量のセットを削除する関数
  const deleteSize = (index: number) => {
    const newSizes = sizes.filter((_, i) => i !== index);
    setSizes(newSizes);
  }

  //indexをmemo化
  useMemo(() => {
    setIndex(sizes.length);
  }, [sizes.length])

  return (
    <div>
      <TableContainer component={Paper}>
        <Table>
          <TableHead>
            <TableRow>
              <TableCell>サイズ</TableCell>
              <TableCell>数量</TableCell>
              <TableCell className={classes.iconCell}/>
              <TableCell className={classes.iconCell}/>
            </TableRow>
          </TableHead>
          <TableBody>
            {sizes.length > 0 && (
              sizes.map((item, i) => (
                <TableRow key={item.size}>
                  <TableCell>{item.size}</TableCell>
                  <TableCell>{item.quantity}</TableCell>
                  <TableCell>
                    <IconButton
                      className={classes.iconCell}
                      onClick={() => editSize(i, {size: item.size, quantity: item.quantity})}>
                      <Edit/>
                    </IconButton>
                  </TableCell>
                  <TableCell>
                    <IconButton className={classes.iconCell} onClick={() => deleteSize(i)}>
                      <Delete/>
                    </IconButton>
                  </TableCell>
                </TableRow>
              ))
            )}
          </TableBody>
        </Table>
        <div>
          <TextInput fullWidth={false} label="サイズ" value={size} onChange={inputSize}/>
          <TextInput fullWidth={false} label="数量" value={quantity} onChange={inputQuantity} type="number"/>
          <IconButton className={classes.checkIcon} onClick={() => addSize(index, {size, quantity})}>
            <CheckCircle/>
          </IconButton>
        </div>
      </TableContainer>
    </div>
  )
}
src/components/products/types.ts
//productのfirebaseに登録する画像の型
export type imageType = {
  id: string;
  path: string;
};

//productのサイズの型
export type sizeType = {
  size: string;
  quantity: number
}
src/components/products/index.ts
export {ImageArea} from "@src/components/products/ImageArea";
export {ImagePreview} from "@src/components/products/ImagePreview";
export {SetSizeArea} from "@src/components/products/SetSizeArea";
src/Router.tsx
import React from "react";
import {Route, Switch} from "react-router";
import {Home, SignIn, SignUp, Reset,ProductEdit} from "@src/pages";
import {Auth} from "@src/Auth";

export const Router: React.VFC = () => {
  return (
    <Switch>
      <Route exact path="/signin" component={SignIn}/>
      <Route exact path="/signin/reset" component={Reset}/>
      <Route exact path="/signup" component={SignUp}/>
      <Auth>
        <Route exact path="(/)?" component={Home}/>
        <Route path="/product/edit(/:id)?" component={ProductEdit}/>
      </Auth>
    </Switch>
  )
}
src/reducks/products/operations.ts
import React from "react";
import {db, FirebaseTimestamp, FirebaseTimestampType} from "@src/firebase";
import {push} from "connected-react-router";
import {imageType, sizeType} from "@src/components/products/types";

//firebaseのdbのproductsを一旦変数に代入
const productsRef = db.collection('products');

//TODO idとcreate_atは必須なのでpartialを無くす
//productのdataの型
//どこかに移動させる
export type productDataType = {
  images: imageType[];
  id?: string;
  name: string;
  description: string;
  category: string;
  gender: string;
  price: number;
  sizes: sizeType[];
  update_at: FirebaseTimestampType;
  created_at?: FirebaseTimestampType;
}

//商品を追加、編集する時に実行される関数
export const saveProducts = (
  id: string,
  images: imageType[],
  name: string,
  description: string,
  category: string,
  gender: string,
  price: string,
  sizes: sizeType[]
) => {
  return async (dispatch: React.Dispatch<unknown>) => {
    //現在の時刻
    const timestamp = FirebaseTimestamp.now();

    //productのdata(更新用)
    const data: productDataType = {
      images,
      name,
      description,
      category,
      gender,
      price: parseInt(price, 10),
      sizes,
      update_at: timestamp,
    }

    //idが空の場合(Productを新規作成の場合)
    if (id === '') {
      //firebaseが自動生成するidを取得する
      const {id: currentId} = productsRef.doc();
      //productのidにfirebaseが自動生成したidをセットする
      data.id = currentId;
      //productのcreate_atに作った時刻をセットする
      data.created_at = timestamp;
    }

    //idが空の場合は上記の新規作成したIDを代入する
    const productId = id || productsRef.doc().id;

    //firebaseのproductのidに上記のdataをセットする
    return productsRef.doc(productId).set(data, {merge: true})
      .then(() => {
        dispatch(push('/'))
      }).catch((e) => {
        throw new Error(e);
      })
  }
}
HishoHisho

変えた部分

正規表現に変更

src/pages/ProductEdit.tsx
 /**
   * URLから商品情報のIDを取得する関数
   * /product/edit/商品ID
   * から/product/edit/を削除する
   * /product/edit/ページの場合は ''
   * その他は商品IDが入る
   */
  const id = window.location.pathname.replace(/\/product\/edit(\/)?/, '');
HishoHisho

良く考えたらconst [price, setPrice] = useState('');の部分number型の方が良いな?

HishoHisho

というかエラーハンドリングしてない!

HishoHisho

TODO

  • form関連のバリデーションをかける(react-hook-form & Yupが候補)
このスクラップは2021/08/25にクローズされました