前回までの記事 アプリ開発を楽しむ【#1:アプリの概要】 アプリ開発を楽しむ【#2:環境構築1(React+TypeScript)】 アプリ開発を楽しむ【#3:環境構築2 (ESLint+Prettier)】 アプリ開発を楽しむ【#4:ヘッダー】 アプリ開発を楽しむ【#5:MyTogoList】 アプリ開発を楽しむ【#6:Reduxで状態管理1】 アプリ開発を楽しむ【#7:Reduxで状態管理2】 アプリ開発を楽しむ【#8:Google Map API】 アプリ開発を楽しむ【#9:新規追加モーダルUI(togo)】 アプリ開発を楽しむ【#10:Google Mapから座標を取得】

前回と前々回で、Togoを追加できるフォームをつくってきました。 今回は、MyTogoListに新しいTogoを追加できるようにAddTogoModalを完成させたいと思います。

1.togoの型を修正

frontend/app/src/types/togo.tsを修正します。

// ここまで追加
import type { MapPosition } from './map';
// ここまで追加

export type Togo = {
  // ここから修正
  id: number | undefined;
  // ここまで修正

  done: boolean;
  location: string;

  // ここから修正
  position: MapPosition;
  // ここまで修正

  tag: string;
};

前回、緯度経度の型定義をつくったので、togoにも反映させておきます。 idは、新規登録の際、定義しないことになるので、修正しておきます。

2.MyTogoListの修正

~~~省略~~~

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

// ここから追加
const initialTogoData = {
  id: undefined,
  done: false,
  location: '',
  tag: '',
  position: {
    lat: 35.6808610662155,
    lng: 139.76856460990368,
  },
};
ここまで追加

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

~~~省略~~~

return (
    <>
      <AddTogoModal

        // ここから追加
        togoData={initialTogoData}
        // ここまで追加

        isOpenAddTogoModal={isOpenAddTogoModal}
        handleCloseAddTogoModal={handleCloseAddTogoModal}
      />
      <Typography
        component="h2"
        variant="h6"
        color="primary"
        gutterBottom
        sx={{ fontWeight: 'bold' }}
      >

~~~省略~~~

Togoの初期データをpropsとして、AddTogoModalに渡しています。

Togoのidの型定義にundefinedを追加したので、MyTogoListの次の部分を修正します。

// ここから修正
const handleChangeTogoDone = (id: number | undefined) => {
    if (id === undefined) {
      return;
    }
    dispatch(updateTogoDone(id));
};

const handleDeleteTogo = (id: number | undefined) => {
  if (id === undefined) {
    return;
  }
  dispatch(deleteTogo(id));
};
// ここまで修正

idが定義されていない場合は、何もしないようにしています。

ついでになりますが、次の部分も修正しておきます。 Togoリストが空の場合に、リストのヘッダーではなく、「登録されているTogoはありません。」と表示するように修正しました。

<Typography
        component="h2"
        variant="h6"
        color="primary"
        gutterBottom
        sx={{ fontWeight: 'bold' }}
      >
        My List
      </Typography>
// ここから追加
{togoList.length ? (
// ここまで追加
        <Table size="small">
          <TableHead>
            <TableRow>
              <TableCell>&nbsp;</TableCell>
              <TableCell>場所</TableCell>
              <TableCell>タグ</TableCell>
              <TableCell>地図</TableCell>
              <TableCell>編集</TableCell>
              <TableCell>削除</TableCell>
            </TableRow>
          </TableHead>
          <TableBody>

            // ここから修正
            {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>

    // ここから追加
      ) : (
        <Typography variant="body1">登録されているToGoはありません</Typography>
      )}
    // ここまで追加

      <Fab
        color="primary"
        aria-label="add"
        size="medium"
        sx={{ position: 'absolute', top: 16, right: 16 }}
        onClick={handleOpenAddTogoModal}
      >
        <AddIcon />
      </Fab>
    </>

3.AddTogoModalの修正

場所とタグ用のステートを用意します。 前回までに、座標の位置情報用のステートを作成していました。同じ様に場所とタグ用のステートをつくります。 また、mapCenterPositionとmapMarkerPositionの初期値の修正も行います。

import type { MapPosition } from '../../types/map';

// ここから追加
import type { Togo } from '../../types/togo';
// ここまで追加

type AddTogoModalProps = {
    
  // ここから追加
  togoData: Togo;
  // ここまで追加

  isOpenAddTogoModal: boolean;
  handleCloseAddTogoModal: () => void;
};

const AddTogoModal: React.FC<AddTogoModalProps> = ({
    
  // ここから追加
  togoData,
  // ここまで追加

  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 [searchLocationKeyword, setSearchLocationKeyword] = useState<string>('');

  // ここから修正
  const [mapCenterPosition, setMapCenterPosition] = useState<MapPosition>(togoData.position);
  // ここまで修正

  // ここから追加
  const [location, setLocation] = useState<string>(togoData.location);
  const [tag, setTag] = useState<string>(togoData.tag);
  // ここまで追加

  // ここから修正
  const [mapMarkerPosition, setMapMarkerPosition] = useState<MapPosition>(togoData.position);
  // ここまで修正

  // ここから追加
  const handleChangeLocation = (e: React.ChangeEvent<HTMLInputElement>) => {
    setLocation(e.target.value);
  };
    
  const handleChangeTag = (e: React.ChangeEvent<HTMLInputElement>) => {
    setTag(e.target.value);
  };
    
  const handleChangeMapMarkerPosition = (position: MapPosition) => {
    setMapMarkerPosition(position);
  };
    
  const handleChangeMapCenterPosition = (position: MapPosition) => {
    setMapCenterPosition(position);
  };
  // ここまで追加

    const createMarker = (e: google.maps.MapMouseEvent) => {
    const lat = e.latLng?.lat();
    const lng = e.latLng?.lng();
    if (lat && lng) {

      // ここから修正
      handleChangeMapMarkerPosition({ lat, lng });
      // ここまで修正

    }
  };

  const geocode = () => {
    const geocoder = new window.google.maps.Geocoder();
    geocoder
      .geocode({ address: searchLocationKeyword }, (results, status) => {
        if (results && status === google.maps.GeocoderStatus.OK) {
          const lat = results[0].geometry?.location.lat();
          const lng = results[0].geometry?.location.lng();

          // ここから修正
          handleChangeMapCenterPosition({ lat, lng });
          // ここまで修正

        }
      })
      .catch((err) => console.log(err));
  };

const handleSearchLocation = () => {
   searchLocation();
};

// ここから追加
const handleAddTogo = () => {
    if (!location || !tag) {
      return;
    }
    const addTogoData: Togo = {
      id: togoList.length,
      done: false,
      location,
      tag,
      position: mapMarkerPosition,
    };
    console.log(addTogoData);
    handleCloseAddTogoModal();
  };
// ここまで追加

return (
    <Dialog open={isOpenAddTogoModal} onClose={handleCloseAddTogoModal} fullWidth maxWidth="md">
      <DialogTitle>あなたが行きたいところを登録しましょう!</DialogTitle>
      <DialogContent>
        <TextField
          autoFocus
          margin="dense"
          id="name"
          label="場所"
          type="text"
          fullWidth
          variant="standard"
                    
          // ここから追加
          value={location}
          onChange={handleChangeLocation}
          // ここまで修正

        />
        <TextField
          margin="dense"
          id="name"
          label="タグ"
          type="text"
          fullWidth
          variant="standard"

          // ここから追加
          value={tag}
          onChange={handleChangeTag}
          // ここまで修正

        />
        <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' }}
            onChange={(e) => setSearchLocationKeyword(e.target.value)}
            onKeyDown={handleKeyPressSearchLocation}
          />
          <IconButton sx={{ p: '10px' }} aria-label="search" onClick={handleSearchLocation}>
            <SearchIcon />
          </IconButton>
        </Paper>
        <Paper elevation={0} sx={{ mt: 2 }}>
          <GMap
            mapStyles={mapStyles}
            zoom={10}
            mapCenterPosition={mapCenterPosition}
            mapOptions={mapOptions}
            handleClick={(e) => createMarker(e)}
          >
            {mapMarkerPosition && <Marker position={mapMarkerPosition} />}
          </GMap>
        </Paper>
      </DialogContent>
      <DialogActions sx={{ mb: 1, ml: 2, display: 'flex', justifyContent: 'flex-start' }}>

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

          登録
        </Button>
        <Button variant="outlined" onClick={handleCloseAddTogoModal}>
          キャンセル
        </Button>
      </DialogActions>
    </Dialog>
  );

とりあえずこれで一度動かしてみて、consoleに登録した内容が表示されればOKです。

4.ReduxのStoreのステートを更新する

frontend/app/src/redux/togoSlice.tsを修正して、togoListのステートに新しいTogoを追加するreducerをつくります。

export const togoSlice = createSlice({
  name: 'togo',
  initialState,
  reducers: {
    getTogoList(state: TogoState, action: PayloadAction<Togo[]>) {
      state.togoList = action.payload;
    },
    updateTogoDone(state: TogoState, action: PayloadAction<number>) {
      state.togoList = state.togoList.map((togo) => {
        if (togo.id === action.payload) {
          togo.done = !togo.done;
        }
        return togo;
      });
    },
    deleteTogo(state: TogoState, action: PayloadAction<number>) {
      state.togoList = state.togoList.filter((togo) => togo.id !== action.payload);
    },

    // ここから追加
    addTogo(state: TogoState, action: PayloadAction<Togo>) {
      state.togoList = [...state.togoList, action.payload];
    },
    // ここまで追加

  },
});

// ここから修正
export const { getTogoList, updateTogoDone, deleteTogo, addTogo } = togoSlice.actions;
// ここまで修正

AddTogoModal.tsxの修正して、ステートを更新できるようにします。

import React, { useState, useEffect } from 'react';

// ここから追加
import { useDispatch, useSelector } from 'react-redux';
// ここまで追加

~~~省略~~~

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

// ここから追加
import { RootState, AppDispatch } from '../../redux/store';
import { addTogo, initialState } from '../../redux/togoSlice';
// ここまで追加

import type { MapPosition } from '../../types/map';
import type { Togo } from '../../types/togo';

~~~省略~~~

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

  // ここから追加
  const dispatch: AppDispatch = useDispatch();
  const { togoList } = useSelector((state: RootState) => state.togo || initialState);
  // ここまで追加

  const [searchLocationKeyword, setSearchLocationKeyword] = useState<string>('');
  const [mapCenterPosition, setMapCenterPosition] = useState<MapPosition>(togoData.position);

~~~省略~~~

const handleAddTogo = () => {
    if (!location || !tag) {
      return;
    }
    const addTogoData: Togo = {

      // ここから修正
      id: togoList.length,
      // ここまで修正

      done: false,
      location,
      tag,
      position: mapMarkerPosition,
    };

    // ここから削除
    console.log(addTogoData)
    // ここまで削除		

    // ここから追加
    dispatch(addTogo(addTogoData));
    // ここまで追加

    handleCloseAddTogoModal();
  };

storeからtogoListを取得しているのは、togoListの配列の要素数を取得して、idに一意の番号を割り当てるためです。実際には、idには値を割り当てず、バックエンドと通信を行います。idは、データベースが自動的に割り当てることになります。 これで、MyTogoListに新しいTogoを追加できるようになりました。

5.フォームの初期化

いまのままですと、新しくTogoを登録し、再びTogoを登録しようとすると、AddTogoModalに前回の値が残ったままになります。これを修正していきます。

AddTogoModal.tsxを修正します。

~~~省略~~~

const [location, setLocation] = useState<string>(togoData.location);
const [tag, setTag] = useState<string>(togoData.tag);
const [mapMarkerPosition, setMapMarkerPosition] = useState<MapPosition>(togoData.position);

// ここから追加
const initializeTogoState = (isOpen: boolean) => {
  if (isOpen) {
    setLocation(togoData.location);
    setTag(togoData.tag);
    setMapMarkerPosition(togoData.position);
    setMapCenterPosition(togoData.position);
  }
};

useEffect(() => {
  initializeTogoState(isOpenAddTogoModal);
}, [isOpenAddTogoModal]);
// ここまで追加

const handleChangeLocation = (e: React.ChangeEvent<HTMLInputElement>) => {
  setLocation(e.target.value);
};

const handleChangeTag = (e: React.ChangeEvent<HTMLInputElement>) => {
  setTag(e.target.value);
};

~~~省略~~~

今回のプロジェクでは、モーダルが開くたびに初期化されるようにしました。

最後ですが、ESLintのrulesの設定に次を追加します。

rules: {
    'no-use-before-define': 'off',
    '@typescript-eslint/no-use-before-define': ['error'],
    'no-param-reassign': 0,
    'react/jsx-uses-react': 'off',
    'react/react-in-jsx-scope': 'off',
    'react/require-default-props': 'off',

    // ここから追加
    'react-hooks/exhaustive-deps': 'off',
    // ここまで追加

    'react/function-component-definition': [2, { namedComponents: 'arrow-function' }], 
    'react/jsx-filename-extension': [
      'error',
      {
        extensions: ['.js', '.jsx', '.ts', '.tsx'],
      },
    ],
    'import/extensions': [
      'error',
      'ignorePackages',
      {
        js: 'never',
        jsx: 'never',
        ts: 'never',
        tsx: 'never',
      },
    ],
  },

今回は以上となります。 次回は、登録したTogoの編集ができるようにしていきたいと思います。

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