LoginSignup
3
6

More than 1 year has passed since last update.

~part3~【FC版】React + Rails API + axios + react-router-domでCRUDを実装する

Last updated at Posted at 2021-08-14

こんばんは!スージーです。
前回書いた記事はこちらです

やりたい事

  • devise_auth_tokenを使った認証
  • Material-UIを使ったスタイル修正

開発環境

Ruby 2.7.1 => 2.7.2
Rails 6.0.4 => 6.1.4
MySQL
node.js 14.8.0 => 16.6.2
React 17.0.2

※開発環境を変更したため、各バージョンが変更になりました。part1から通して作り直してみましたが、新旧バージョンで動作に影響はありません

参考

認証周りは上記のQiita記事通りに実装させてもらいました。大変分かりやすく、簡単に実装することができました。ありがとうございました。

やらないこと

  • Rails

    • メール認証
    • SNS認証
    • パスワード再発行
  • React

    • Material-uiの詳しい説明

Rails側から実装開始

backendはこちらの記事に書かれている通りに実装しています

gem 'devise'
gem 'devise_token_auth'
backkend $ bundle install
rails g devise:install
rails g devise_token_auth:install User auth
rails db:migrate
# app/config/initializers/devise_token_auth.rb
# 以下のブロッグをコメント解除
DeviseTokenAuth.setup do |config|
  config.change_headers_on_each_request = false
  config.token_lifespan = 2.weeks
  config.token_cost = Rails.env.test? ? 4 : 10

  config.headers_names = {:'access-token' => 'access-token',
                         :'client' => 'client',
                         :'expiry' => 'expiry',
                         :'uid' => 'uid',
                         :'token-type' => 'token-type' }
end
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "localhost:5000"

    resource "*",
      headers: :any,
      expose: ["access-token", "expiry", "token-type", "uid", "client"], # 追記
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

コントローラ作成

rails g controller api/v1/auth/registrations
rails g controller api/v1/auth/sessions
# registrations_controller.rb
class Api::V1::Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController
  private

    def sign_up_params
      params.permit(:email, :password, :password_confirmation, :name)
    end
end
# sessions_controller.rb
class Api::V1::Auth::SessionsController < ApplicationController
  def index
    if current_api_v1_user
      render json: { is_login: true, data: current_api_v1_user }
    else
      render json: { is_login: false, message: "ユーザーが存在しません" }
    end
  end
end

※2021/08/15 ソースのコピペミスで誤ったjsonを返す記述をしていたので修正しました

2021/08/15 追記

ログイン状態で画面を何度かリロードすると、ログイン画面にリダイレクトされる不安定な挙動がありました。症状はcookieにuid, client, access_tokenが有効なものがあるにも関わらず、ログインが解除されているという事象です。DBの情報を見てもtokensカラムにはtokenが入っています。
こちらの記事を参考に以下の修正を行ったところ挙動が安定するようになりました。

# devise_auth_token.rb
# 8行目をコメントアウトとtrue => falseに修正
change_headers_on_each_request = false

change_headers_on_each_requestですが、

デフォルトではそれぞれのリクエストの後にaccess-tokenヘッダが変わります。クライアントは変更されるトークンを追跡し続ける責務があります。ng-token-authとj-tokerの両方でこれが実行されます。これは安全ですが、管理が難しいです。この設定をfalseにすると、リクエスト毎にトークンヘッダが変更されなくなります。

トークンヘッダが変わるため、リロードを何度も繰り返すと追跡がうまくいかない可能性があります。まだちゃんと調査したわけでは無いので、詳しい方いましたら、ご教示お願いいたします。

# applicatio_controller.rb
class ApplicationController < ActionController::Base
  include DeviseTokenAuth::Concerns::SetUserByToken

  skip_before_action :verify_authenticity_token
  helper_method :current_user, :user_signed_in?
end

ルーティング

# routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :posts

      mount_devise_token_auth_for 'User', at: 'auth', controllers: {
        registrations: 'api/v1/auth/registrations'
      }

      namespace :auth do
        resources :sessions, only: %i[index]
      end
    end
  end
end

動作確認

// ユーザー作成
curl -X POST http://localhost:5000/api/v1/auth -d "[name]=test&[email]=test@example.com&[password]=password&[password_confirmation]=password"
・
・・
・・・
"status": "success",

// サインイン
curl -X POST -v http://localhost:5000/api/v1/auth/sign_in -d "[email]=test@example.com&[password]=password"
・
・・
・・・
< HTTP/1.1 200 OK

成功レスポンスが返ってくればOK

次にReact側を実装

認証周りと一緒にMaterial-UIを使ってレイアウトも修正していきます

まずAPIを用意します。ついでにMaterial-UIのライブラリもインストールします

frontend $ npm install js-cookie
npm install @material-ui/core @material-ui/icons @material-ui/lab
touch src/lib/api/auth.js

こちらの記事同様に実装するのでjs-cookieを追加します

// src/lib/api/auth.js
import client from './client';
import Cookies from 'js-cookie';

export const signUp = (params) => {
  return client.post('/auth', params);
};

export const signIn = (params) => {
  return client.post('/auth/sign_in', params);
};

export const signOut = () => {
  return client.delete('/auth/sign_out', {
    headers: {
      'access-token': Cookies.get('_access_token'),
      client: Cookies.get('_client'),
      uid: Cookies.get('_uid'),
    },
  });
};

export const getCurrentUser = () => {
  if (
    !Cookies.get('_access_token') ||
    !Cookies.get('_client') ||
    !Cookies.get('_uid')
  )
    return;
  return client.get('/auth/sessions', {
    headers: {
      'access-token': Cookies.get('_access_token'),
      client: Cookies.get('_client'),
      uid: Cookies.get('_uid'),
    },
  });
};

 この記事では最終的に以下のディレクトリ構造になっています
スクリーンショット 2021-08-09 9.40.54.png

// App.jsx
import React, { useState, useEffect, createContext } from 'react';
import {
  BrowserRouter as Router,
  Switch,
  Route,
  Redirect,
} from 'react-router-dom';
// component
import List from './components/List';
import New from './components/New';
import Detail from './components/Detail';
import Edit from './components/Edit';
import Header from './components/commons/Header';
import SignUp from './components/users/SignUp';
import SignIn from './components/users/SignIn';
import MainContainer from './components/layout/MainContainer';
// style
import { CssBaseline } from '@material-ui/core';
import { StylesProvider, ThemeProvider } from '@material-ui/styles';
import { theme } from './styles/theme';

import { getCurrentUser } from './lib/api/auth';

export const AuthContext = createContext();

const App = () => {
  const [loading, setLoading] = useState(true);
  const [isSignedIn, setIsSignedIn] = useState(false);
  const [currentUser, setCurrentUser] = useState();

  const handleGetCurrentUser = async () => {
    try {
      const res = await getCurrentUser();

      if (res?.data.isLogin === true) {
        setIsSignedIn(true);
        setCurrentUser(res?.data.data);
        console.log(res?.data.data);
      } else {
        console.log('no current user');
      }
    } catch (e) {
      console.log(e);
    }

    setLoading(false);
  };

  useEffect(() => {
    handleGetCurrentUser();
  }, [setCurrentUser]);

  const Private = ({ children }) => {
    if (!loading) {
      if (isSignedIn) {
        return children;
      } else {
        return <Redirect to='/signin' />;
      }
    } else {
      return <></>;
    }
  };

  return (
    <>
      <StylesProvider injectFirst>
        <ThemeProvider theme={theme}>
          <AuthContext.Provider
            value={{
              loading,
              setLoading,
              isSignedIn,
              setIsSignedIn,
              currentUser,
              setCurrentUser,
            }}
          >
            <CssBaseline />

            <Router>
              <Header />
              <MainContainer>
                <Switch>
                  <Route exact path='/signup' component={SignUp} />
                  <Route exact path='/signin' component={SignIn} />
                  <Private>
                    <Route exact path='/' component={List} />
                    <Route path='/post/:id' component={Detail} />
                    <Route exact path='/new' component={New} />
                    <Route path='/edit/:id' component={Edit} />
                  </Private>
                </Switch>
              </MainContainer>
            </Router>
          </AuthContext.Provider>
        </ThemeProvider>
      </StylesProvider>
    </>
  );
};
export default App;

Contextを使って認証情報をグローバルに管理しています。<Private></Private>でラップするコンポーネントには認証中でないとアクセスできないようになっています

Material-UIで共通で使う色など変更したり、breakpointを変えたり、フォントを変えたり等々する時の為にtheme.jsを作成します

// src/styles/theme.js
import { createTheme } from '@material-ui/core/styles';

export const theme = createTheme({
  overrides: {
    MuiCssBaseline: {
      '@global': {
        html: {
          WebkitFontSmoothing: 'auto',
        },
      },
    },
  },
  breakpoints: {
    values: {
      xs: 0,
      sm: 768,
      md: 960,
      lg: 1280,
      xl: 1920,
    },
  },
  props: {
    MuiButtonBase: {
      disableRipple: false,
    },
    MuiFilledInput: {
      color: 'secondary',
    },
  },
  palette: {
    primary: {
      main: '#1976d2',
      contrastText: '#ffffff',
      light: '#4791db',
      dark: '#115293',
    },
    secondary: {
      main: '#dc004e',
      light: '#e33371',
      dark: '#9a0036',
    },
    error: {
      main: '#FF2E2E',
    },
    background: {
      default: '#ffffff',
    },
  },
  typography: {
    fontFamily: [
      'Noto Sans JP',
      'Hiragino Kaku Gothic Pro',
      'ヒラギノ角ゴ Pro',
      'Yu Gothic Medium',
      '游ゴシック Medium',
      'YuGothic',
      '游ゴシック体',
      'メイリオ',
      'sans-serif',
    ].join(','),
  },
});

画面真ん中に要素を配置できるように全てのコンポーネント共通で使うレイアウトを作成します

// src/components/layout/MainContainer.jsx
import React from 'react';
// style
import { Container, Grid } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';

const useStyles = makeStyles(() => ({
  container: {
    marginTop: '3rem',
  },
}));

const MainContainer = ({ children }) => {
  const classes = useStyles();

  return (
    <>
      <main>
        <Container maxWidth='lg' className={classes.container}>
          <Grid container justifyContent='center'>
            <Grid item>{children}</Grid>
          </Grid>
        </Container>
      </main>
    </>
  );
};
export default MainContainer;

上のコンポーネントをApp.jsxにimportして<Switch></Switch>をラップして全てのコンポーネントに適用します

サインアップ、サインイン用のコンポーネントを作成します。フォーム部分は共通で使いまわします

// src/components/users/SignIn.jsx
import React, { useState, useContext } from 'react';
import { useHistory } from 'react-router-dom';
import Cookies from 'js-cookie';
// context
import { AuthContext } from '../../App';
// api
import { signIn } from '../../lib/api/auth';
// component
import SignForm from './SignForm';

const SignIn = () => {
  const history = useHistory();

  const { setIsSignedIn, setCurrentUser } = useContext(AuthContext);

  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const signInHandleSubmit = async (e) => {
    e.preventDefault();

    const params = generateParams();

    try {
      const res = await signIn(params);

      if (res.status === 200) {
        Cookies.set('_access_token', res.headers['access-token']);
        Cookies.set('_client', res.headers['client']);
        Cookies.set('_uid', res.headers['uid']);

        setIsSignedIn(true);
        setCurrentUser(res.data.data);

        history.push('/');
      }
    } catch (e) {
      console.log(e);
    }
  };

  const generateParams = () => {
    const signInParams = {
      email: email,
      password: password,
    };
    return signInParams;
  };

  return (
    <SignForm
      email={email}
      setEmail={setEmail}
      password={password}
      setPassword={setPassword}
      handleSubmit={signInHandleSubmit}
      signType='signIn'
    />
  );
};
export default SignIn;
// src/components/users/SignUp.jsx
import React, { useState, useContext } from 'react';
import { useHistory } from 'react-router-dom';
import Cookies from 'js-cookie';
// context
import { AuthContext } from '../../App';
// api
import { signUp } from '../../lib/api/auth';
// component
import SignForm from './SignForm';

const SignUp = () => {
  const history = useHistory();

  const { setIsSignedIn, setCurrentUser } = useContext(AuthContext);

  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [passwordConfirmation, setPasswordConfirmation] = useState('');

  const signUpHandleSubmit = async (e) => {
    e.preventDefault();

    const params = generateParams();

    try {
      const res = await signUp(params);
      console.log(res);

      if (res.status === 200) {
        Cookies.set('_access_token', res.headers['access-token']);
        Cookies.set('_client', res.headers['client']);
        Cookies.set('_uid', res.headers['uid']);

        setIsSignedIn(true);
        setCurrentUser(res.data.data);

        history.push('/');
        console.log('signed in successfully');
      }
    } catch (e) {
      console.log(e);
    }
  };

  const generateParams = () => {
    const signUpParams = {
      name: name,
      email: email,
      password: password,
      passwordConfirmation: passwordConfirmation,
    };
    return signUpParams;
  };

  return (
    <SignForm
      name={name}
      setName={setName}
      email={email}
      setEmail={setEmail}
      password={password}
      setPassword={setPassword}
      passwordConfirmation={passwordConfirmation}
      setPasswordConfirmation={setPasswordConfirmation}
      handleSubmit={signUpHandleSubmit}
      signType='signUp'
    />
  );
};
export default SignUp;
// src/components/users/SignForm.jsx
import React from 'react';
import { Link } from 'react-router-dom';
// style
import { makeStyles } from '@material-ui/core/styles';
import {
  Typography,
  TextField,
  Card,
  CardContent,
  CardHeader,
  Button,
  Box,
} from '@material-ui/core';

const useStyles = makeStyles((theme) => ({
  container: {
    marginTop: theme.spacing(6),
  },
  submitBtn: {
    marginTop: theme.spacing(2),
    flexGrow: 1,
    textTransform: 'none',
  },
  header: {
    textAlign: 'center',
  },
  card: {
    padding: theme.spacing(2),
    maxWidth: 400,
  },
  box: {
    marginTop: '2rem',
  },
  link: {
    textDecoration: 'none',
  },
}));

const SignForm = (props) => {
  const {
    email,
    setEmail,
    password,
    setPassword,
    handleSubmit,
    signType,
    name,
    setName,
    passwordConfirmation,
    setPasswordConfirmation,
  } = props;
  const classes = useStyles();

  return (
    <>
      <form noValidate autoComplete='off'>
        <Card className={classes.card}>
          <CardHeader className={classes.header} title={signType} />
          <CardContent>
            {signType === 'signUp' && (
              <TextField
                variant='outlined'
                required
                fullWidth
                label='Name'
                value={name}
                margin='dense'
                onChange={(event) => setName(event.target.value)}
              />
            )}
            <TextField
              variant='outlined'
              required
              fullWidth
              label='Email'
              value={email}
              margin='dense'
              onChange={(event) => setEmail(event.target.value)}
            />
            <TextField
              variant='outlined'
              required
              fullWidth
              label='Password'
              type='password'
              placeholder='At least 6 characters'
              value={password}
              margin='dense'
              autoComplete='current-password'
              onChange={(event) => setPassword(event.target.value)}
            />
            {signType === 'signUp' && (
              <TextField
                variant='outlined'
                required
                fullWidth
                label='Password Confirmation'
                type='password'
                value={passwordConfirmation}
                margin='dense'
                autoComplete='current-password'
                onChange={(event) =>
                  setPasswordConfirmation(event.target.value)
                }
              />
            )}
            <Button
              type='submit'
              variant='contained'
              size='large'
              fullWidth
              color='default'
              disabled={!email || !password ? true : false}
              className={classes.submitBtn}
              onClick={handleSubmit}
            >
              Submit
            </Button>
            {signType === 'signIn' && (
              <Box textAlign='center' className={classes.box}>
                <Typography variant='body2'>
                  Don't have an account? &nbsp;
                  <Link to='/signup' className={classes.link}>
                    Sign Up now!
                  </Link>
                </Typography>
              </Box>
            )}
          </CardContent>
        </Card>
      </form>
    </>
  );
};
export default SignForm;

propsで受け取ったsignTypesignInなのかsignUpなのかで、namepasswordConfirmationのテキストフィールドの出し分け、signupへのリンクの出し分けを行っています

各画面のスタイルを修正

前回の記事で作成した各コンポーネントにMaterial-UIでスタイルを当てていきます

ヘッダー
ログイン・サインアップ・ログアウトの各種ボタンとハンバーガーメニューを作成します

// components/commons/Header.jsx
import React, { useContext, useState } from 'react';
import { useHistory, Link } from 'react-router-dom';
import Cookies from 'js-cookie';
// style
import { createStyles, makeStyles } from '@material-ui/core/styles';
import {
  AppBar,
  Toolbar,
  Typography,
  Button,
  IconButton,
} from '@material-ui/core';
import MenuIcon from '@material-ui/icons/Menu';
// api
import { signOut } from '../../lib/api/auth';
// context
import { AuthContext } from '../../App';
// component
import HeaderDrawer from './HeaderDrawer';

const useStyles = makeStyles((theme) =>
  createStyles({
    root: {
      flexGrow: 1,
    },
    menuButton: {
      marginRight: theme.spacing(2),
    },
    title: {
      flexGrow: 1,
    },
    linkBtn: {
      textTransform: 'none',
    },
  })
);

const drawerItem = [
  { label: '一覧へ戻る', path: '/' },
  { label: '新規作成', path: '/new' },
  { label: '自分の投稿', path: '#' },
];

const Header = () => {
  const { loading, isSignedIn, setIsSignedIn, currentUser } =
    useContext(AuthContext);
  const classes = useStyles();
  const history = useHistory();

  const handleSignOut = async (e) => {
    try {
      const res = await signOut();

      if (res.data.success === true) {
        Cookies.remove('_access_token');
        Cookies.remove('_client');
        Cookies.remove('_uid');

        setIsSignedIn(false);
        history.push('/signin');
        console.log('succeeded in sign out');
      } else {
        console.log('failed in sign out');
      }
    } catch (e) {
      console.log(e);
    }
  };

  const AuthButtons = () => {
    if (!loading) {
      if (isSignedIn) {
        return (
          <Button
            color='inherit'
            className={classes.linkBtn}
            onClick={handleSignOut}
          >
            Sign out
          </Button>
        );
      } else {
        return (
          <>
            <Button
              component={Link}
              to='/signin'
              color='inherit'
              className={classes.linkBtn}
            >
              Sign in
            </Button>
            <Button
              component={Link}
              to='/signup'
              color='inherit'
              className={classes.linkBtn}
            >
              Sign Up
            </Button>
          </>
        );
      }
    } else {
      return <></>;
    }
  };

  const [open, setOpen] = useState(false);

  const handleDrawerToggle = () => {
    setOpen(!open);
  };

  return (
    <>
      <div className={classes.root}>
        <AppBar position='static'>
          <Toolbar>
            {isSignedIn && currentUser && (
              <IconButton
                edge='start'
                className={classes.menuButton}
                color='inherit'
                aria-label='menu'
                onClick={handleDrawerToggle}
              >
                <MenuIcon />
              </IconButton>
            )}
            <Typography variant='h6' className={classes.title}>
              React Rails API Practice
            </Typography>
            <AuthButtons />
          </Toolbar>
        </AppBar>
      </div>
      <HeaderDrawer
        open={open}
        handleDrawerToggle={handleDrawerToggle}
        drawerItem={drawerItem}
      />
    </>
  );
};
export default Header;

未ログイン時はSign InSign Upボタンが表示されます。ログイン中はSign Outボタンが表示されます。またハンバーガーメニューはログイン中しか表示されないように制御しています

ドロワーは別コンポーネントで作成します

こんな感じでヘッダーが表示されればOK
スクリーンショット 2021-08-11 18.17.54.png

// components/commons/HeaderDrawer.jsx
import React from 'react';
import { Link } from 'react-router-dom';
import { createStyles, makeStyles, useTheme } from '@material-ui/core/styles';
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft';
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import {
  IconButton,
  Drawer,
  List,
  Divider,
  ListItem,
  ListItemText,
} from '@material-ui/core';

const drawerWidth = 240;

const useStyles = makeStyles((theme) =>
  createStyles({
    drawer: {
      width: drawerWidth,
      flexShrink: 0,
    },
    drawerPaper: {
      width: drawerWidth,
    },
    drawerHeader: {
      display: 'flex',
      alignItems: 'center',
      padding: theme.spacing(0, 1),
      // necessary for content to be below app bar
      ...theme.mixins.toolbar,
      justifyContent: 'flex-end',
    },
  })
);

const HeaderDrawer = (props) => {
  const { open, handleDrawerToggle, drawerItem } = props;
  const classes = useStyles();
  const theme = useTheme();

  return (
    <Drawer
      className={classes.drawer}
      variant='temporary'
      anchor='left'
      open={open}
      classes={{ paper: classes.drawerPaper }}
    >
      <div className={classes.drawerHeader}>
        <IconButton onClick={handleDrawerToggle}>
          {theme.direction === 'ltr' ? (
            <ChevronLeftIcon />
          ) : (
            <ChevronRightIcon />
          )}
        </IconButton>
      </div>
      <Divider />
      <List>
        {drawerItem.map((item, index) => (
          <ListItem
            component={Link}
            to={item.path}
            key={index}
            onClick={handleDrawerToggle}
          >
            <ListItemText primary={item.label} />
          </ListItem>
        ))}
      </List>
    </Drawer>
  );
};
export default HeaderDrawer;

こんな感じでドロワーが表示されればOK
screencapture-localhost-5100-2021-08-11-18_17_32.png

HOME画面

// List.jsx
import React, { useEffect, useState } from 'react';
import { getList, deletePost } from '../lib/api/post';
import { useHistory, Link } from 'react-router-dom';
import SpaceRow from './commons/SpaceRow';
// style
import {
  Button,
  TableContainer,
  Table,
  TableBody,
  TableCell,
  TableRow,
  TableHead,
  Paper,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
// functions
import { subString } from '../functions/functions';

const useStyles = makeStyles({
  table: {
    minWidth: 650,
  },
  fontWeight: {
    fontWeight: 900,
  },
});

const List = () => {
  const classes = useStyles();
  const [dataList, setDataList] = useState([]);

  useEffect(() => {
    handleGetList();
  }, []);

  const handleGetList = async () => {
    try {
      const res = await getList();
      console.log(res.data);
      setDataList(res.data);
    } catch (e) {
      console.log(e);
    }
  };

  const history = useHistory();

  const handleDelete = async (item) => {
    console.log('click', item.id);
    try {
      const res = await deletePost(item.id);
      console.log(res.data);
      handleGetList();
    } catch (e) {
      console.log(e);
    }
  };

  return (
    <>
      <h1>HOME</h1>
      <Button
        variant='contained'
        color='primary'
        onClick={() => history.push('/new')}
      >
        新規作成
      </Button>
      <SpaceRow height={20} />
      <TableContainer component={Paper}>
        <Table className={classes.table} aria-label='simple table'>
          <TableHead>
            <TableRow>
              <TableCell align='center' className={classes.fontWeight}>
                名前
              </TableCell>
              <TableCell align='center' className={classes.fontWeight}>
                猫種
              </TableCell>
              <TableCell align='center' className={classes.fontWeight}>
                好きな食べ物
              </TableCell>
              <TableCell align='center' className={classes.fontWeight}>
                好きなおもちゃ
              </TableCell>
              <TableCell align='center'></TableCell>
              <TableCell align='center'></TableCell>
              <TableCell align='center'></TableCell>
            </TableRow>
          </TableHead>
          <TableBody>
            {dataList.map((item, index) => (
              <TableRow key={index}>
                <TableCell align='center'>{subString(item.name, 15)}</TableCell>
                <TableCell align='center'>
                  {subString(item.nekoType, 15)}
                </TableCell>
                <TableCell align='center'>
                  {subString(item.detailInfo.favoriteFood, 10)}
                </TableCell>
                <TableCell align='center'>
                  {subString(item.detailInfo.favoriteToy, 10)}
                </TableCell>
                <TableCell align='center'>
                  <Link to={`/edit/${item.id}`}>更新</Link>
                </TableCell>
                <TableCell align='center'>
                  <Link to={`/post/${item.id}`}>詳細へ</Link>
                </TableCell>
                <TableCell align='center'>
                  <Button
                    variant='contained'
                    color='secondary'
                    onClick={() => handleDelete(item)}
                  >
                    削除
                  </Button>
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </TableContainer>
    </>
  );
};
export default List;

長い文字列の時にテーブルレイアウトが崩れてしまうので、一定文字数以上は「...」に置き換える関数subStringを用意します

// functions/functions.js
export function subString(string, num) {
  const name = string;
  if (name.length > num) {
    const splitName = name.substring(0, num);
    return splitName + '...';
  } else {
    return name;
  }
}

引数にstringnumを渡してあげます

// こんな感じで使います
{subString(item.nekoType, 15)}

こんな感じで表示されます
スクリーンショット 2021-08-11 18.02.04.png

要素にmarginを入れる為にstyleタグやclassNameをつけるのが手間なのでmarginコンポーネント作ります

// components/commons/SpaceRow.jsx
import React from 'react';

const SpaceRow = (props) => {
  return <div style={{ marginTop: props.height, width: '100%' }} />;
};

export default SpaceRow;
// こんな感じで使います
<SpaceRow height={20} />

こんな感じでmarginが取れます
スクリーンショット 2021-08-11 18.08.33.png

詳細画面

// Detail.jsx
import React, { useEffect, useState } from 'react';
import { getDetail } from '../lib/api/post';
import { useHistory, useParams } from 'react-router-dom';
import SpaceRow from './commons/SpaceRow';
// style
import {
  Button,
  TableContainer,
  Table,
  TableBody,
  TableCell,
  TableRow,
  Paper,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';

const useStyles = makeStyles({
  table: {
    minWidth: 300,
  },
  fontWeight: {
    fontWeight: 900,
  },
});

const Detail = () => {
  const classes = useStyles();
  const [data, setData] = useState({
    name: '',
    neko_type: '',
    detailInfo: {
      favorite_food: '',
      favorite_toy: '',
    },
  });

  const query = useParams();
  console.log(query.id);

  const history = useHistory();

  useEffect(() => {
    handleGetDetail(query);
  }, [query]);

  const handleGetDetail = async (query) => {
    try {
      const res = await getDetail(query.id);
      console.log(res.data);
      setData(res.data);
    } catch (e) {
      console.log(e);
    }
  };

  return (
    <>
      <h1>DETAIL</h1>
      <Button
        variant='contained'
        color='primary'
        onClick={() => history.push('/')}
      >
        戻る
      </Button>
      <SpaceRow height={20} />
      <TableContainer component={Paper} className={classes.table}>
        <Table className={classes.table} aria-label='simple table'>
          <TableBody>
            <TableRow>
              <TableCell align='right' className={classes.fontWeight}>
                ID:
              </TableCell>
              <TableCell align='center'>{data.id}</TableCell>
            </TableRow>
            <TableRow>
              <TableCell align='right' className={classes.fontWeight}>
                名前:
              </TableCell>
              <TableCell align='center'>{data.name}</TableCell>
            </TableRow>
            <TableRow>
              <TableCell align='right' className={classes.fontWeight}>
                猫種:
              </TableCell>
              <TableCell align='center'>{data.nekoType}</TableCell>
            </TableRow>
            <TableRow>
              <TableCell align='right' className={classes.fontWeight}>
                好きな食べ物:
              </TableCell>
              <TableCell align='center'>
                {data.detailInfo.favoriteFood}
              </TableCell>
            </TableRow>
            <TableRow>
              <TableCell align='right' className={classes.fontWeight}>
                好きなおもちゃ:
              </TableCell>
              <TableCell align='center'>
                {data.detailInfo.favoriteToy}
              </TableCell>
            </TableRow>
          </TableBody>
        </Table>
      </TableContainer>
    </>
  );
};
export default Detail;

新規登録画面と編集画面

// Form.jsx
import React from 'react';
import { useHistory } from 'react-router-dom';
import SpaceRow from './commons/SpaceRow';
import { makeStyles } from '@material-ui/core/styles';
import { Button, TextField } from '@material-ui/core';

const useStyles = makeStyles((theme) => ({
  root: {
    '& > *': {
      margin: theme.spacing(1),
      width: '25ch',
    },
  },
}));

const Form = (props) => {
  const classes = useStyles();
  const history = useHistory();
  const { handleChange, handleSubmit, value, buttonType } = props;
  return (
    <>
      <Button
        type='submit'
        variant='contained'
        color='primary'
        onClick={(e) => handleSubmit(e)}
        style={{ marginRight: 10 }}
      >
        {buttonType}
      </Button>
      <Button variant='contained' onClick={() => history.push('/')}>
        戻る
      </Button>
      <SpaceRow height={20} />
      <form className={classes.root} noValidate autoComplete='off'>
        <TextField
          id='name'
          label='猫の名前'
          type='text'
          name='name'
          onChange={(e) => handleChange(e)}
          value={value.name}
        />
        <TextField
          id='nekoType'
          label='猫種'
          type='text'
          name='nekoType'
          onChange={(e) => handleChange(e)}
          value={value.nekoType}
        />
        <TextField
          id='favoriteFood'
          label='好きな食べ物'
          type='text'
          name='favoriteFood'
          onChange={(e) => handleChange(e)}
          value={value.favoriteFood}
        />
        <TextField
          id='favoriteToy'
          label='好きなおもちゃ'
          type='text'
          name='favoriteToy'
          onChange={(e) => handleChange(e)}
          value={value.favoriteToy}
        />
      </form>
    </>
  );
};
export default Form;

これで全てのページのレイアウトをある程度整えることができました。
キャプチャのようなレイアウトになっていればOK
スクリーンショット 2021-08-14 18.16.43.png

まとめ

今回は認証周りとレイアウトの修正を行いました。次はUserとPostの関連付けを行っていこうと思います

おわり
3
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
6