前回までの記事 アプリ開発を楽しむ【#1:アプリの概要】 アプリ開発を楽しむ【#2:環境構築1(React+TypeScript)】 アプリ開発を楽しむ【#3:環境構築2 (ESLint+Prettier)】 アプリ開発を楽しむ【#4:ヘッダー】 アプリ開発を楽しむ【#5:MyTogoList】 アプリ開発を楽しむ【#6:Reduxで状態管理1】 アプリ開発を楽しむ【#7:Reduxで状態管理2】 アプリ開発を楽しむ【#8:Google Map API】

今回は、togoを追加するモーダル(UIのみ)をつくっていきたいと思います。 完成形は次のイメージです。 スクリーンショット 2022-09-03 15.21.58.png

いきなりすべてをつくろうとすると、途中でどこが間違っているのかわからなくなったりするので少しずつつくっていきます。

1.場所とタグのテキストボックス

まずは次のイメージのUIをつくっていきます。 スクリーンショット 2022-09-02 19.48.47.png

frontend/app/src/components/togo/AddTogoModal.tsxを次のようにつくります。

import React from 'react';
import {
  Button,
  Dialog,
  DialogActions,
  DialogContent,
  DialogTitle,
  TextField,
} from '@mui/material';

const AddTogoModal: React.FC = () => (
  <Dialog open fullWidth maxWidth="md">
    <DialogTitle>あなたが行きたいところを登録しましょう!</DialogTitle>
    <DialogContent>
      <TextField
        autoFocus
        margin="dense"
        id="name"
        label="場所"
        type="text"
        fullWidth
        variant="standard"
      />
      <TextField margin="dense" id="name" label="タグ" type="text" fullWidth variant="standard" />
    </DialogContent>
    <DialogActions sx={{ mb: 1, ml: 2, display: 'flex', justifyContent: 'flex-start' }}>
      <Button variant="contained">登録</Button>
      <Button variant="outlined">キャンセル</Button>
    </DialogActions>
  </Dialog>
);

export default AddTogoModal;

Material UIのDialogコンポーネントに open を渡すことでModalが常に開いた状態となります。 (後で、MyTogoListの+ボタンを押すことで表示させるようにします。)

<Dialog open fullWidth maxWidth="md">

frontend/app/src/components/togo/MyTogoList.tsxを修正します。 AddTogoModalのコンポーネントをimportして、追加しているだけです。

import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
  Checkbox,
  Chip,
  Fab,
  Link,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableRow,
  Typography,
} from '@mui/material';
import {
  Add as AddIcon,
  Delete as DeleteIcon,
  LocationOn as LocationOnIcon,
  ModeEditOutline as ModeEditOutlineIcon,
} from '@mui/icons-material';
import { RootState, AppDispatch } from '../../redux/store';
import { getTogoList, updateTogoDone, deleteTogo, initialState } from '../../redux/togoSlice';

// ここから追加
import AddTogoModal from './AddTogoModal';
// ここまで追加

import sampleTogoList from '../../sampleData/togo';

const MyTogoList = () => {
  const dispatch: AppDispatch = useDispatch();

  const { togoList } = useSelector((state: RootState) => state.togo || initialState);

  useEffect(() => {
    dispatch(getTogoList(sampleTogoList));
  }, [dispatch]);

  const handleChangeTogoDone = (id: number) => {
    dispatch(updateTogoDone(id));
  };

  const handleDeleteTogo = (id: number) => {
    dispatch(deleteTogo(id));
  };

  return (
    <>
      // ここから追加
      <AddTogoModal />
      // ここまで追加

      <Typography
        component="h2"
        variant="h6"
        color="primary"
        gutterBottom
        sx={{ fontWeight: 'bold' }}
      >
        My List
      </Typography>
      <Table size="small">
        <TableHead>
          <TableRow>
            <TableCell>&nbsp;</TableCell>
            <TableCell>場所</TableCell>
            <TableCell>タグ</TableCell>
            <TableCell>地図</TableCell>
            <TableCell>編集</TableCell>
            <TableCell>削除</TableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {togoList &&
            togoList.map((item) => (
              <TableRow key={item.id}>
                <TableCell>
                  <Checkbox checked={item.done} onChange={() => handleChangeTogoDone(item.id)} />
                </TableCell>
                <TableCell>{item.location}</TableCell>
                <TableCell>
                  <Chip label={item.tag} color="primary" size="small" />
                </TableCell>
                <TableCell>
                  <Link
                    href={`https://maps.google.co.jp/maps?ll=${item.position.lat},${item.position.lng}&z=20`}
                    underline="none"
                    target="_blank"
                    rel="noopener"
                  >
                    <LocationOnIcon />
                  </Link>
                </TableCell>
                <TableCell>
                  <ModeEditOutlineIcon sx={{ cursor: 'pointer' }} />
                </TableCell>
                <TableCell>
                  <DeleteIcon
                    sx={{ cursor: 'pointer' }}
                    onClick={() => handleDeleteTogo(item.id)}
                  />
                </TableCell>
              </TableRow>
            ))}
        </TableBody>
      </Table>
      <Fab
        color="primary"
        aria-label="add"
        size="medium"
        sx={{ position: 'absolute', top: 16, right: 16 }}
      >
        <AddIcon />
      </Fab>
    </>
  );
};

export default MyTogoList;

次のように表示されていればOKです。 スクリーンショット 2022-09-02 19.48.47.png

2.地図の表示

次はGoogle Mapを表示させます。 frontend/app/src/components/common/GMap.tsxを次のようにつくります。

import React from 'react';
import { GoogleMap, LoadScript } from '@react-google-maps/api';

type GMapProps = {
  children?: React.ReactNode;
  mapStyles: { height: string; width: string };
  zoom: number;
  mapCenterPosition: { lat: number; lng: number };
  mapOptions: {
    gestureHandling: string;
    zoomControl: boolean;
    scaleControl: boolean;
    streetViewControl: boolean;
    panControl: boolean;
    mapTypeControl: boolean;
    fullscreenControl: boolean;
  };
};

const GMap: React.FC<GMapProps> = ({
  children,
  mapStyles,
  zoom,
  mapCenterPosition,
  mapOptions,
}) => (
  <LoadScript googleMapsApiKey={import.meta.env.VITE_GOOGLE_MAP_API_KEY}>
    <GoogleMap
      mapContainerStyle={mapStyles}
      zoom={zoom}
      center={mapCenterPosition}
      options={mapOptions}
    >
      {children}
    </GoogleMap>
  </LoadScript>
);
export default GMap;

この部分はGMapコンポーネントが受け取るpropsの型定義をしています。

type GMapProps = {
  children?: React.ReactNode;
  // css
  mapStyles: { height: string; width: string };
  // Google Mapの縮尺
  zoom: number;
  // 地図を表示させたときの中央の緯度経度
  mapCenterPosition: { lat: number; lng: number };
  // 地図表示のオプション
  mapOptions: {
    // cooperativeを指定するとスクロール時にcommandボタンが必要
    gestureHandling: string;
    zoomControl: boolean;
    scaleControl: boolean;
    streetViewControl: boolean;
    panControl: boolean;
    mapTypeControl: boolean;
    fullscreenControl: boolean;
  };
};
// .envからGoogle Map Apiキーを読み込んでいます。
<LoadScript googleMapsApiKey={import.meta.env.VITE_GOOGLE_MAP_API_KEY}>
  // Google Mapライブラリーに受け取ったpropsを渡しています
  <GoogleMap
    mapContainerStyle={mapStyles}
    zoom={zoom}
    center={mapCenterPosition}
    options={mapOptions}
  />
</LoadScript>

frontend/app/src/components/todo/AddTogoModal.tsxを修正 つくったGMapコンポーネントをAddTogoModal追加します。

import React from 'react';

// ここから追加
import { Marker } from '@react-google-maps/api';
// ここまで追加

import {
  Button,
  Dialog,
  DialogActions,
  DialogContent,
  DialogTitle,

  // ここから追加
  Paper,
  // ここまで追加

  TextField,

} from '@mui/material';

// ここから追加
import GMap from '../common/GMap';
// ここまで追加

const AddTogoModal: React.FC = () => {

  // ここから追加
  const mapStyles = {
    height: '500px',
    width: '100%',
  };

  const mapOptions = {
    gestureHandling: 'cooperative',
    zoomControl: false,
    scaleControl: false,
    streetViewControl: false,
    panControl: false,
    mapTypeControl: false,
    fullscreenControl: false,
  };

  const mapCenterPosition = { lat: 35.6808610662155, lng: 139.76856460990368 };
  const mapMarkerPosition = { lat: 35.6808610662155, lng: 139.76856460990368 };
  // ここまで追加

  return (
    <Dialog open fullWidth maxWidth="md">
      <DialogTitle>あなたが行きたいところを登録しましょう!</DialogTitle>
      <DialogContent>
        <TextField
          autoFocus
          margin="dense"
          id="name"
          label="場所"
          type="text"
          fullWidth
          variant="standard"
        />
        <TextField margin="dense" id="name" label="タグ" type="text" fullWidth variant="standard" />
                
    // ここから追加
        <Paper elevation={0} sx={{ mt: 2 }}>
           <GMap
            mapStyles={mapStyles}
            zoom={10}
            mapCenterPosition={mapCenterPosition}
            mapOptions={mapOptions}
          >
            // Google Map上にMarkerを表示させています
            {mapMarkerPosition && <Marker position={mapMarkerPosition} />}
          </GMap>
        </Paper>
      // ここまで追加

      </DialogContent>
      <DialogActions sx={{ mb: 1, ml: 2, display: 'flex', justifyContent: 'flex-start' }}>
        <Button variant="contained">登録</Button>
        <Button variant="outlined">キャンセル</Button>
      </DialogActions>
    </Dialog>
  );
};

export default AddTogoModal;

Goole Mapを表示することができました。 スクリーンショット 2022-09-02 21.53.30.png

3.Search Google Mapsのテキストボックス

frontend/app/src/components/togo/AddTogoModa.tsxを修正します。

import React from 'react';
import {
  Button,
  Dialog,
  DialogActions,
  DialogContent,
  DialogTitle,

  // ここから追加
  IconButton,
  InputBase,
  // ここまで追加

  Paper,
  TextField,
} from '@mui/material';

// ここから追加
import SearchIcon from '@mui/icons-material/Search';
// ここまで追加

import GMap from '../common/GMap';

const AddTogoModal: React.FC = () => {
  const mapStyles = {
    height: '500px',
    width: '100%',
  };

  const mapOptions = {
    gestureHandling: 'cooperative',
    zoomControl: false,
    scaleControl: false,
    streetViewControl: false,
    panControl: false,
    mapTypeControl: false,
    fullscreenControl: false,
  };

  const mapCenterPosition = { lat: 35.6808610662155, lng: 139.76856460990368 };

  return (
    <Dialog open fullWidth maxWidth="md">
      <DialogTitle>あなたが行きたいところを登録しましょう!</DialogTitle>
      <DialogContent>
        <TextField
          autoFocus
          margin="dense"
          id="name"
          label="場所"
          type="text"
          fullWidth
          variant="standard"
        />
        <TextField margin="dense" id="name" label="タグ" type="text" fullWidth variant="standard" />

        // ここから追加
        <Paper
          component="form"
          elevation={2}
          sx={{ p: '2px 4px', m: '10px 0px', display: 'flex', alignItems: 'center', width: 400 }}
        >
          <InputBase
            sx={{ ml: 1, flex: 1 }}
            placeholder="Search Google Maps"
            inputProps={{ 'aria-label': 'search google maps' }}
          />
          <IconButton sx={{ p: '10px' }} aria-label="search">
            <SearchIcon />
          </IconButton>
        </Paper>
        // ここまで追加

        <Paper elevation={0} sx={{ mt: 2 }}>
          <GMap
            mapStyles={mapStyles}
            zoom={10}
            mapCenterPosition={mapCenterPosition}
            mapOptions={mapOptions}
          />
        </Paper>
      </DialogContent>
      <DialogActions sx={{ mb: 1, ml: 2, display: 'flex', justifyContent: 'flex-start' }}>
        <Button variant="contained">登録</Button>
        <Button variant="outlined">キャンセル</Button>
      </DialogActions>
    </Dialog>
  );
};

export default AddTogoModal;

スクリーンショット 2022-09-03 14.28.09.png

これでUIは完成です。

4.モーダルをボタンで操作

AddTogoModalの開閉には、状態の管理が必要となります。 状態の管理には、Reduxを使ってもよいのですが、今回はuseStateを使ってコンポーネントで管理することにします。 frontend/app/src/components/togo/MyTogoList.tsxを修正

// ここから修正
import { useEffect, useState } from 'react';
// ここまで修正

import { useDispatch, useSelector } from 'react-redux';
import {
  Checkbox,
  Chip,
  Fab,
  Link,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableRow,
  Typography,
} from '@mui/material';
import {
  Add as AddIcon,
  Delete as DeleteIcon,
  LocationOn as LocationOnIcon,
  ModeEditOutline as ModeEditOutlineIcon,
} from '@mui/icons-material';
import { RootState, AppDispatch } from '../../redux/store';
import { getTogoList, updateTogoDone, deleteTogo, initialState } from '../../redux/togoSlice';
import AddTogoModal from './AddTogoModal';
import sampleTogoList from '../../sampleData/togo';

const MyTogoList = () => {
  const dispatch: AppDispatch = useDispatch();

  const { togoList } = useSelector((state: RootState) => state.togo || initialState);

  // ここから追加
  const [isOpenAddTogoModal, setIsOpenAddTogoModal] = useState<boolean>(false);
  // ここまで追加

  useEffect(() => {
    dispatch(getTogoList(sampleTogoList));
  }, [dispatch]);

  const handleChangeTogoDone = (id: number) => {
    dispatch(updateTogoDone(id));
  };

  const handleDeleteTogo = (id: number) => {
    dispatch(deleteTogo(id));
  };

  // ここから追加
  const handleOpenAddTogoModal = () => {
    setIsOpenAddTogoModal(true);
  };

  const handleCloseAddTogoModal = () => {
    setIsOpenAddTogoModal(false);
  };
  // ここまで追加

  return (
    <>

      // ここから修正
      <AddTogoModal
        isOpenAddTogoModal={isOpenAddTogoModal}
        handleCloseAddTogoModal={handleCloseAddTogoModal}
      />
      // ここまで修正

      <Typography
        component="h2"
        variant="h6"
        color="primary"
        gutterBottom
        sx={{ fontWeight: 'bold' }}
      >
        My List
      </Typography>
      <Table size="small">
        <TableHead>
          <TableRow>
            <TableCell>&nbsp;</TableCell>
            <TableCell>場所</TableCell>
            <TableCell>タグ</TableCell>
            <TableCell>地図</TableCell>
            <TableCell>編集</TableCell>
            <TableCell>削除</TableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {togoList &&
            togoList.map((item) => (
              <TableRow key={item.id}>
                <TableCell>
                  <Checkbox checked={item.done} onChange={() => handleChangeTogoDone(item.id)} />
                </TableCell>
                <TableCell>{item.location}</TableCell>
                <TableCell>
                  <Chip label={item.tag} color="primary" size="small" />
                </TableCell>
                <TableCell>
                  <Link
                    href={`https://maps.google.co.jp/maps?ll=${item.position.lat},${item.position.lng}&z=20`}
                    underline="none"
                    target="_blank"
                    rel="noopener"
                  >
                    <LocationOnIcon />
                  </Link>
                </TableCell>
                <TableCell>
                  <ModeEditOutlineIcon sx={{ cursor: 'pointer' }} />
                </TableCell>
                <TableCell>
                  <DeleteIcon
                    sx={{ cursor: 'pointer' }}
                    onClick={() => handleDeleteTogo(item.id)}
                  />
                </TableCell>
              </TableRow>
            ))}
        </TableBody>
      </Table>
      <Fab
        color="primary"
        aria-label="add"
        size="medium"
        sx={{ position: 'absolute', top: 16, right: 16 }}
                
        // ここから追加
        onClick={handleOpenAddTogoModal}
        // ここまで追加

      >
        <AddIcon />
      </Fab>
    </>
  );
};

export default MyTogoList;

frontend/app/src/components/AddTogoModal.tsxを修正

import React from 'react';
import { Marker } from '@react-google-maps/api';
import {
  Button,
  Dialog,
  DialogActions,
  DialogContent,
  DialogTitle,
  IconButton,
  InputBase,
  Paper,
  TextField,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import GMap from '../common/GMap';

// ここから追加
type AddTogoModalProps = {
  isOpenAddTogoModal: boolean;
  handleCloseAddTogoModal: () => void;
};
// ここまで追加

// ここから修正
const AddTogoModal: React.FC<AddTogoModalProps> = ({
  isOpenAddTogoModal,
  handleCloseAddTogoModal,
}) => {
// ここまで修正

  const mapStyles = {
    height: '500px',
    width: '100%',
  };

  const mapOptions = {
    gestureHandling: 'cooperative',
    zoomControl: false,
    scaleControl: false,
    streetViewControl: false,
    panControl: false,
    mapTypeControl: false,
    fullscreenControl: false,
  };

  const mapCenterPosition = { lat: 35.6808610662155, lng: 139.76856460990368 };
  const mapMarkerPosition = { lat: 35.6808610662155, lng: 139.76856460990368 };

  return (

    // ここから修正
    <Dialog open={isOpenAddTogoModal} onClose={handleCloseAddTogoModal} fullWidth maxWidth="md">
    // ここまで修正

      <DialogTitle>あなたが行きたいところを登録しましょう!</DialogTitle>
      <DialogContent>
        <TextField
          autoFocus
          margin="dense"
          id="name"
          label="場所"
          type="text"
          fullWidth
          variant="standard"
        />
        <TextField margin="dense" id="name" label="タグ" type="text" fullWidth variant="standard" />
        <Paper
          component="form"
          elevation={2}
          sx={{ p: '2px 4px', m: '10px 0px', display: 'flex', alignItems: 'center', width: 400 }}
        >
          <InputBase
            sx={{ ml: 1, flex: 1 }}
            placeholder="Search Google Maps"
            inputProps={{ 'aria-label': 'search google maps' }}
          />
          <IconButton sx={{ p: '10px' }} aria-label="search">
            <SearchIcon />
          </IconButton>
        </Paper>
        <Paper elevation={0} sx={{ mt: 2 }}>
          <GMap
            mapStyles={mapStyles}
            zoom={10}
            mapCenterPosition={mapCenterPosition}
            mapOptions={mapOptions}
          >
            {mapMarkerPosition && <Marker position={mapMarkerPosition} />}
          </GMap>
        </Paper>
      </DialogContent>
      <DialogActions sx={{ mb: 1, ml: 2, display: 'flex', justifyContent: 'flex-start' }}>
        <Button variant="contained">登録</Button>

        // ここから修正
        <Button variant="outlined" onClick={handleCloseAddTogoModal}>
        // ここまで修正

          キャンセル
        </Button>
      </DialogActions>
    </Dialog>
  );
};

export default AddTogoModal;

useStateを使ってAddTogoModal(開閉)の状態を管理します。

const [isOpenAddTogoModal, setIsOpenAddTogoModal] = useState<boolean>(false);

子コンポーネントのAddTogoModalにpropsを渡しています。 isOpenAddTogoModalが状態 handleCloseAddTogoModal関数を渡しておくことで、子コンポーネントのAddTogoModalでhandleCloseAddTogoModalが実行されると、isOpenAddTogoModalの状態がfalseになります。

<AddTogoModal
  isOpenAddTogoModal={isOpenAddTogoModal}
  handleCloseAddTogoModal={handleCloseAddTogoModal}
/>

open={isOpenAddTogoModal}は、Material UIのDialogを表示するか非表示にするかを判定しています。 onCloseは、Material UIのDialogの外をクリックしたときのメソッドなどを指定します。ここでは、handleCloseAddTogoを渡して、isOpenAddTogoModalの状態をfalseにするメソッド(モーダルを閉じる)を渡しています。

    <Dialog open={isOpenAddTogoModal} onClose={handleCloseAddTogoModal} fullWidth maxWidth="md">

これでMyTogoListの+ボタンで、AddTogoModalを表示できるようになりましたし、キャンセルボタンまたはモーダルの外をクリックするとAddTogoModalが非表示になります。 今回はここまでです。

コードはGitHubに置いてありますのでよければ参考にしてください。 mainブランチは常に最新のものになります。 今回の内容はblog_9のブランチを参照してください。 https://github.com/KINE-M/togo_app