フロントエンド側 React ダイアログ修正/ローディング/権限/リロード

記念すべきパクリ30発目!!
パクリ続けて300年、と早くなりたいもんだ

パクリ元

内容

前回とりあえず終わった気でいたが、ちょっと気になったところをやっぱやる事にした。なんか中途半端で気持ち悪かったため。

  • モーダル部分やり直し
  • ページローディング
  • 地図ローディング
  • ユーザー権限によるメニュー制御
  • リロード対応

モーダル部分やり直し

パクリ元1つ目のページを見つけたのでやり直すことにする。開閉まで含めていたモーダル状態から開閉を抜いて、messageDialogState.ts から modalSettingState.ts に名前も変更し別の状態として持つようにする。

stores/modal/modalState.ts
import { atomFamily } from 'recoil'

export type ModalType =
  | 'message'
  | 'confirm'
export const ModalState = atomFamily({
  key: 'ModalState',
  default: false,
})
components/hooks/useModal.tsx
import { useRecoilState, SetterOrUpdater } from 'recoil'
import { ModalState, ModalType } from '@/src/stores/modal/modalState'

type Response = [
  boolean,
  SetterOrUpdater<boolean>
]

export const useModal = (modalType: ModalType): Response => {
  const [isModalVisible, setIsModalVisible] = useRecoilState(ModalState(modalType))

  return [isModalVisible, setIsModalVisible]
}
stores/modal/modalSettingState.ts
import { atom } from "recoil";

export const modalSettingState = atom({
 key: 'modalSettingState', 
 default: {
    dialogTitle: '',
    dialogMessages: [],
    okCaption:  '',
    cancelCaption:  '',
    isOkAutoFocus: true,
    onOkFunc: () => {},
    onCancelFunc: () => {},
 } as ModalSetting,
});
components/types/ModalSetting.d.ts
declare type ModalSetting = {
    dialogTitle: string;        // タイトル
    dialogMessages: string[];   // メッセージ
    okCaption: string;          // OK時のボタンタイトル
    cancelCaption: string;      // CANCEL時のボタンタイトル
    isOkAutoFocus: boolean;     // true:OKボタンにフォーカス
    onOkFunc: () => void;       // OK時処理
    onCancelFunc: () => void;   // CANCEL時処理
};

ログイン前にも対応するレイアウトが必要だと思っていたので、今まで使用していた Layout.tsx をAuthLayout.tsx として、新たに AuthLessLayout.tsx を作成して、全共通部分はそちらに移すことに。メニューもサイドメニュー側に移す。

routes/Router.tsx
・・・
+import { AuthLayout } from '@/src/components/composite/AuthLayout';
+import { AuthLessLayout } from '@/src/components/composite/AuthLessLayout';
-import { Layout } from '@/src/components/composite/Layout';
-import { MessageDialog } from "@/src/components/composite/MessageDialog";
・・・
export const Router = () => {
  return (
    <Routes>
      <Route path="/" element={<AuthLessLayout />}>
        <Route path="/" element={<Login />} />
        <Route path={Routing.Login.path} element={<Login />} />
        <Route element={<PrivateRoutes />}>
          <Route path="/" element={<AuthLayout />}>
            <Route path={Routing.StadiumMap.path} element={<StadiumMap />} />
            <Route path={Routing.Stadium.path} element={<Stadium />} />
            <Route path={Routing.Todo.path} element={<Todo />} />
          </Route>
        </Route>
        <Route path="*" element={<ErrorPage404 />} />
      </Route>
    </Routes>
  );
};
components/composite/AuthLayout.tsx
import { useState } from 'react';

import { styled } from '@mui/material/styles';

import { QR } from '@/src/features/share-application/qr/components/QR';
import { Header } from '@/src/components/composite/Header';
import { SideBar } from '@/src/features/sidebar/sidebar-list/components/SideBar';

import { useRecoilState } from "recoil";
import { Outlet } from 'react-router-dom';
import { menuItemsState } from "@/src/stores/menuItem/menuItemsState";
import { operateMenuState } from "@/src/stores/operateMenu/operateMenuState";
import { headerHeight } from '@/src/components/composite/Header';

const Container = styled('div')({
  margin: 0,
  maxWidth: '100%',
  top: headerHeight,
  height: `90vh`,
  overflowY: 'auto',
  fontFamily: '-apple-system, BlinkMacSystemFont, Roboto, sans-serif',
});
export const AuthLayout = () => {
  const [qrOpen, setQrOpen] = useState(false);
  const [drawerOpen, setDrawerOpen] = useState(false);
  const [menuItems, setMenuItems] = useRecoilState<MenuItem>(menuItemsState)
  const [operateMenu, setOperateMenu] = useRecoilState<OperateMenu>(operateMenuState)


  const handleToggleQR = () => {
    setQrOpen((qrOpen) => !qrOpen);
  };

  const handleToggleDrawer = () => {
    setDrawerOpen((drawerOpen) => !drawerOpen);
  };

  return (
    <>
      <Header filter={'all'} onToggleDrawer={handleToggleDrawer} />
      <SideBar
        drawerOpen={drawerOpen}
        onToggleDrawer={handleToggleDrawer}
        menuItems={menuItems}
        operateMenu={operateMenu}
      />
      <Container sx={{pl: {xs: 0, sm: '250px'}}}>
        <Outlet />
      </Container>
      <QR open={qrOpen} onClose={handleToggleQR} />
    </>
  );
};
components/composite/MessageModal.tsx
import { useModal } from '@/src/components/hooks/useModal'
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import { styled } from '@mui/material/styles';

type Props = {
  modalSetting: ModalSetting;
};

export const MessageModal = (props: Props) => {
  const [isModalVisible, setIsModalVisible] = useModal('message')

  const Alert = styled(Dialog)(() => ({
    fontFamily: '-apple-system, BlinkMacSystemFont, Roboto, sans-serif',
  }));

  let dialogMessages: JSX.Element[] = [];
  props.modalSetting.dialogMessages.forEach((message: string) => {
    dialogMessages.push(<DialogContentText>{message}</DialogContentText>);
  });

  return (
    isModalVisible && (
      <Alert  open={isModalVisible} onClose={() => setIsModalVisible(false)}>
        <DialogTitle>{props.modalSetting.dialogTitle}</DialogTitle>
        <DialogContent>
          {dialogMessages}
        </DialogContent>
        <DialogActions>
          <Button
            aria-label="alert-ok"
            onClick={() => {
              props.modalSetting.onOkFunc();
            }}
            color="primary"
            autoFocus={props.modalSetting.isOkAutoFocus}
          >
            {props.modalSetting.okCaption}
          </Button>
        </DialogActions>
      </Alert>
    )
  )
}
components/composite/ConfirmModal.tsx
import { useModal } from '@/src/components/hooks/useModal'
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import { styled } from '@mui/material/styles';

type Props = {
  modalSetting: ModalSetting;
};

export const ConfirmModal = (props: Props) => {
  const [isModalVisible, setIsModalVisible] = useModal('confirm')

  const Alert = styled(Dialog)(() => ({
    fontFamily: '-apple-system, BlinkMacSystemFont, Roboto, sans-serif',
  }));

  let dialogMessages: JSX.Element[] = [];
  props.modalSetting.dialogMessages.forEach((message: string) => {
    dialogMessages.push(<DialogContentText>{message}</DialogContentText>);
  });

  return (
    isModalVisible && (
      <Alert  open={isModalVisible} onClose={() => setIsModalVisible(false)}>
        <DialogTitle>{props.modalSetting.dialogTitle}</DialogTitle>
        <DialogContent>
          {dialogMessages}
        </DialogContent>
        <DialogActions>
          {props.modalSetting.cancelCaption !== '' && <Button
            aria-label="alert-cancel"
            onClick={() => {
              props.modalSetting.onCancelFunc();
            }}
            color="primary"
            autoFocus={!props.modalSetting.isOkAutoFocus}
          >
            {props.modalSetting.cancelCaption}
          </Button>}
          <Button
            aria-label="alert-ok"
            onClick={() => {
              props.modalSetting.onOkFunc();
            }}
            color="secondary"
            autoFocus={props.modalSetting.isOkAutoFocus}
          >
            {props.modalSetting.okCaption}
          </Button>
        </DialogActions>
      </Alert>
    )
  )
}
components/composite/AuthLessLayout.tsx
import GlobalStyles from '@mui/material/GlobalStyles';
import { createTheme, ThemeProvider, styled } from '@mui/material/styles';
import { indigo, pink } from '@mui/material/colors';
import { Outlet } from 'react-router-dom';
import { ConfirmModal } from "@/src/components/composite/ConfirmModal";
import { MessageModal } from "@/src/components/composite/MessageModal";
import { modalSettingState } from "@/src/stores/modal/modalSettingState";
import { useRecoilState } from "recoil";

const theme = createTheme({
  palette: {
    primary: {
      main: indigo[500],
      light: '#757de8',
      dark: '#002984',
    },
    secondary: {
      main: pink[500],
      light: '#ff6090',
      dark: '#b0003a',
    },
  },
});

export const AuthLessLayout = () => {
  const [modalSetting, setModalSetting] = useRecoilState<ModalSetting>(modalSettingState);

  return (
    <ThemeProvider theme={theme}>
      <GlobalStyles styles={{ body: { margin: 0, padding: 0 } }} />
      <ConfirmModal modalSetting={modalSetting} />
      <MessageModal modalSetting={modalSetting} />
      <Outlet />
    </ThemeProvider>
  );
};

後は使用する側の変更。

features/login/login-form/components/Login.tsx
・・・
-import { messageDialogState } from "@/src/stores/messageDialog/messageDialogState";
+import { useModal } from '@/src/components/hooks/useModal'
+import { modalSettingState } from "@/src/stores/modal/modalSettingState";
・・・
-  const [messageDialog, setMessageDialog] = useRecoilState<MessageDialog>(messageDialogState);
+  const [modalSetting, setModalSetting] = useRecoilState<ModalSetting>(modalSettingState);
+  const [isModalVisible, setIsModalVisible] = useModal('message')
・・・
-        setMessageDialog({
-          dialogOpen: true,
+        setModalSetting({
・・・
-          onOkFunc: () => {setMessageDialog({...messageDialog, dialogOpen: false})},
-          onCancelFunc: () => {setMessageDialog({...messageDialog, dialogOpen: false})},
+          onOkFunc: () => {setIsModalVisible(false)},
+          onCancelFunc: () => {},
         });
+        setIsModalVisible(true);
・・・
features/stadium/stadium-list/components/Stadium.tsx
・・・
-import { messageDialogState } from "@/src/stores/messageDialog/messageDialogState";
+import { useModal } from '@/src/components/hooks/useModal'
+import { modalSettingState } from "@/src/stores/modal/modalSettingState";
・・・
-  const [messageDialog, setMessageDialog] = useRecoilState<MessageDialog>(messageDialogState);
+  const [modalSetting, setModalSetting] = useRecoilState<ModalSetting>(modalSettingState);
+  const [isModalVisible, setIsModalVisible] = useModal('message')
・・・
-        setMessageDialog({
-          dialogOpen: true,
+        setModalSetting({
・・・
-          onOkFunc: () => {setMessageDialog({...messageDialog, dialogOpen: false})},
-          onCancelFunc: () => {setMessageDialog({...messageDialog, dialogOpen: false})},
+          onOkFunc: () => {setIsModalVisible(false)},
+          onCancelFunc: () => {},
         });
+        setIsModalVisible(true);
・・・
features/todo/todo-list/components/ActionButton.tsx
・・・
-import { messageDialogState } from "@/src/stores/messageDialog/messageDialogState";
+import { useModal } from '@/src/components/hooks/useModal'
+import { modalSettingState } from "@/src/stores/modal/modalSettingState";
・・・
-  const [messageDialog, setMessageDialog] = useRecoilState<MessageDialog>(messageDialogState);
+  const [modalSetting, setModalSetting] = useRecoilState<ModalSetting>(modalSettingState);
+  const [isModalVisible, setIsModalVisible] = useModal('message')
・・・
-            setMessageDialog({
-              dialogOpen: true,
-              dialogTitle: 'ログイン失敗',
+            setModalSetting({
+              dialogTitle: '確認',
・・・
-              onOkFunc: () => { setMessageDialog({...messageDialog, dialogOpen: false});props.onEmpty(); },
-              onCancelFunc: () => { setMessageDialog({...messageDialog, dialogOpen: false}) },
+              onOkFunc: () => { setIsModalVisible(false); props.onEmpty(); },
+              onCancelFunc: () => { setIsModalVisible(false) },
         });
+        setIsModalVisible(true);
・・・
features/sidebar/sidebar-list/components/SideBar.tsx
import Icon from '@mui/material/Icon';
import List from '@mui/material/List';
import Avatar from '@mui/material/Avatar';
import Drawer from '@mui/material/Drawer';
import Divider from '@mui/material/Divider';
import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import ListItemButton from '@mui/material/ListItemButton';

import { styled } from '@mui/material/styles';
import { indigo, pink } from '@mui/material/colors';

import * as pjson from '@/src/../package.json';

import { headerHeight } from '@/src/components/composite/Header';

import Collapse from '@mui/material/Collapse';
import ExpandLess from '@mui/icons-material/ExpandLess';
import ExpandMore from '@mui/icons-material/ExpandMore';

import { useState, useEffect } from 'react';

import { useRecoilState, useResetRecoilState } from "recoil";
import { accountState } from "@/src/stores/account/accountState";
import { menuItemsState } from "@/src/stores/menuItem/menuItemsState";
import { operateMenuState } from "@/src/stores/operateMenu/operateMenuState";

import { useNavigate } from 'react-router-dom';
import { Routing } from "@/src/routes/routing";


type Props = {
  drawerOpen: boolean;
  onToggleDrawer: () => void;
  menuItems: MenuItem;
  operateMenu: OperateMenu;
};

const DrawerList = styled('div')(() => ({
  width: 250,
}));

const DrawerHeader = styled('div')(() => ({
  height: 150,
  display: 'flex',
  flexDirection: 'column',
  justifyContent: 'center',
  alignItems: 'center',
  padding: '1em',
  backgroundColor: indigo[500],
  color: '#ffffff',
  fontFamily: '-apple-system, BlinkMacSystemFont, Roboto, sans-serif',
}));

const DrawerAvatar = styled(Avatar)(({ theme }) => ({
  backgroundColor: pink[500],
  width: theme.spacing(6),
  height: theme.spacing(6),
}));

const drawer = (props: Props) => {
  const [account, setAccount] = useRecoilState(accountState)

  let ret: JSX.Element[] = [];
  Object.keys(props.menuItems).forEach((itemId, idx) => {
    if (idx > 0) {
      ret.push(<Divider />);
    }
    ret.push(
      <ListItemButton selected={itemId === props.operateMenu.selectMenuId} onClick={()=>{(Object.keys(props.menuItems[itemId].subitems).length===0)?props.onToggleDrawer():()=>{};props.menuItems[itemId].onClick()}}>
        <ListItemIcon>
          <Icon sx={props.menuItems[itemId].iconsx}>{props.menuItems[itemId].icon}</Icon>
        </ListItemIcon>
        <ListItemText primary={props.menuItems[itemId].text} />
        {(Object.keys(props.menuItems[itemId].subitems).length === 0) ? null:
          ( props.operateMenu.openMenuId === itemId ?
            (props.operateMenu.openMenu ? <ExpandLess /> : <ExpandMore />) :
          <ExpandMore />)}
      </ListItemButton>
    );

    let sub: JSX.Element[] = [];
    Object.keys(props.menuItems[itemId].subitems).forEach((subItemId: string, subIdx) => {
      sub.push(
        <ListItem disablePadding onClick={props.onToggleDrawer}>
          <ListItemButton
            aria-label={subItemId}
            selected={subItemId === props.operateMenu.selectMenuId}
            onClick={props.menuItems[itemId].subitems[subItemId].onClick}
          >
            <ListItemIcon>
              <Icon sx={props.menuItems[itemId].subitems[subItemId].iconsx}>{props.menuItems[itemId].subitems[subItemId].icon}</Icon>
            </ListItemIcon>
            <ListItemText secondary={props.menuItems[itemId].subitems[subItemId].text} />
          </ListItemButton>
        </ListItem>
      );
    });
    ret.push(
      <Collapse in={props.operateMenu.openMenuId === itemId && props.operateMenu.openMenu} timeout="auto" unmountOnExit>
        <List sx={{paddingLeft: 2}}>
          {sub}
        </List>
      </Collapse>
    );
  });

  return (
  <>
    <DrawerList role="presentation">
      <DrawerHeader>
        <DrawerAvatar>
          <Icon>create</Icon>
        </DrawerAvatar>
        <p>TODO v{pjson.version}</p>
        <p>{account.name}様</p>
      </DrawerHeader>
        {ret}
    </DrawerList>
  </>)
};

export const SideBar = (props: Props) => {
  const [qrOpen, setQrOpen] = useState(false);
  const [menuItems, setMenuItems] = useRecoilState<MenuItem>(menuItemsState)
  const [operateMenu, setOperateMenu] = useRecoilState<OperateMenu>(operateMenuState)

  const resetAccountState = useResetRecoilState(accountState);
  const navigate = useNavigate();

  const handleToggleQR = () => {
    setQrOpen((qrOpen) => !qrOpen);
  };

  const handleToggleMenu = (openMenuId: string) => {
    setOperateMenu((operateMenu) => ({...operateMenu, openMenuId: openMenuId, openMenu: !operateMenu.openMenu}));
  };

  useEffect(() => {
    let items: MenuItem = {};
    items['stadium'] = { text: 'スタジアム', icon: 'stadium', iconsx: null, onClick:  () => {navigate(Routing.StadiumMap.path); handleToggleMenu('stadium')}, subitems: {} };
    items['task'] = { text: 'タスク', icon: 'task', iconsx: null, onClick:  () => {navigate(Routing.Todo.path); handleToggleMenu('task')}, subitems: {} };
    items['share'] = { text: 'このアプリを共有', icon: 'share', iconsx: null, onClick: handleToggleQR, subitems: {} };
    items['logout'] = { text: 'ログアウト', icon: 'logout', iconsx: null, onClick: () => { resetAccountState(); navigate("/login");}, subitems: {} };

    setMenuItems(items);
  },[]) 

  return (
    <>
    <Drawer
      variant="temporary"
      open={props.drawerOpen}
      onClose={props.onToggleDrawer}
      ModalProps={{
        keepMounted: true,
      }}
      sx={{
        display: { xs: 'block', sm: 'none' }, // 変更:xsまで(スマホ)はハンバーガーメニューアイコンクリックからの一時表示
        '& .MuiDrawer-paper': { 
          boxSizing: 'border-box',
          top: headerHeight,
          height: `calc(100% - ${headerHeight})`,
        },
      }}
    >
      {drawer(props)}
    </Drawer>
    <Drawer
      variant="permanent"
      PaperProps={{ elevation: 4 }}
      sx={{
        display: { xs: 'none', sm: 'block' }, // 変更:smから(PC等)は常時表示
        '& .MuiDrawer-paper': {
          boxSizing: 'border-box',
          top: headerHeight,
          height: `calc(100% - ${headerHeight})`,
        },
      }}
    >
      {drawer(props)}
    </Drawer>
    </>
  );
}

カスタムフックというのはこういう風に作るものなんでしょうかね?いまいち分からんけど

ページローディング

パクリ元2つ目、3つ目、4つ目を参考にローディング表示する。4つ目の wrapPromise みたいのが見つかるまで結構ハマった。また、子コンポーネントで取得時は親コンポーネント側で Suspense をかましておかないといけないというのもハマった点。

components/composite/AuthLayout.tsx
・・・
import { useState, Suspense } from 'react';
import CircularProgress from '@mui/material/CircularProgress';
・・・
         <Suspense fallback={<div style={{display: 'flex', justifyContent: 'center', alignItems: 'center', width: '100%', height: '100%'}}><CircularProgress /></div>}>
           <Outlet />
         </Suspense>
・・・
components/logics/wrapPromise.ts
export const wrapPromise = (promise: Promise<any>) => {
  let status = 'pending';
  let result: any;
  let suspender = promise.then(
    (r) => {
      status = 'success';
      result = r;
    },
    (e) => {
      status = 'error';
      result = e;
    }
  );
  return {
    read() {
      if (status === 'pending') {
        throw suspender;
      } else if (status === 'error') {
        throw result;
      } else if (status === 'success') {
        return result;
      }
    },
  };
}
features/stadium/stadium-list/components/Stadium.tsx
・・・
import { wrapPromise } from '@/src/components/logics/wrapPromise'
・・・
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));// テスト用
export const Stadium = () => {
・・・
  const getStadiumData = async (): Promise<any> => {
    await sleep(3000);// テスト用
    return await getStadiums();
  }

  useEffect(() => {
    try {
      const stadiums = wrapPromise(getStadiumData())
      setRecords(stadiums.read)
    } catch {
・・・
    };
  }, []);

  return (
    <>
      <StadiumDetail
        stadiumDetailStatus={stadiumDetailStatus}
        onToggleDrawer={handleToggleDrawer}
      />
      {(records[0].id !== 0) && <DataGrid
        rows={records}
        columns={cols}
        slots={{ toolbar: GridToolbar }}
        sx={{ overflowY: 'scroll', pb: 0}}
        onCellClick={(event) => {
          handleToggleDrawer(records[event.row.id - 1].stadium_name);
        }}
        localeText={jaJP.components.MuiDataGrid.defaultProps.localeText}
      />}
    </>
  );
 };

何とかうまくいった。パクリ元5つ目に DataGrid コンポーネントにあるローディングが見つかったが、Suspense という機能で統一した方が後々管理が楽になると思う。しかし一方、地図表示側だと何度もクルクルが回りだしてしまった。地図も Suspense に囲まれているので表示したタイミングで緯度経度が更新されているような動き。地図でやる場合は別の方法を取る必要がありそうだ。

地図ローディング

マーカー部分を切り出して子コンポーネントにして、それを Suspense で囲うように修正。

features/stadium/stadium-list/components/StadiumMap.tsx
import { useEffect, useState, useCallback, Suspense } from 'react';
import { useRecoilState } from "recoil";
import { StadiumDetail } from "@/src/features/stadium/stadium-detail/components/StadiumDetail";
import { StadiumMapMarker } from "@/src/features/stadium/stadium-list/components/StadiumMapMarker";

import {APIProvider, Map, Marker, MapEvent} from '@vis.gl/react-google-maps';
import { menuItemsState } from "@/src/stores/menuItem/menuItemsState";
import { addSubItems } from "@/src/components/logics/addSubItems";
import { useNavigate } from "react-router-dom";
import { Routing } from '@/src/routes/routing';
import CircularProgress from '@mui/material/CircularProgress';

export const StadiumMap = () => {
  const [records, setRecords] = useState([{"id": 0, team_name: "", stadium_name: "", latitude: 0, longitude: 0, capacity: 0} as Stadium]);
  const [stadiumDetailStatus, setStadiumDetailStatus] = useState({} as StadiumDetailStatus);
  const [latLon, setLatLon] = useState({"lat": 0, "lon": 0});
  const [menuItems, setMenuItems] = useRecoilState<MenuItem>(menuItemsState)
  const navigate = useNavigate();

  const handleToggleDrawer = (_stadiumName: string) => {
    setStadiumDetailStatus((stadiumDetailStatus) => ({isOpen: !stadiumDetailStatus.isOpen, stadiumName: _stadiumName}));
  };

  const handleIdle = useCallback((ev: MapEvent) => {
    setLatLon({"lat": ev.map.getCenter()?.toJSON().lat??0, "lon": ev.map.getCenter()?.toJSON().lng??0})
  }, []);

  useEffect(() => {
    let subitems: MenuItem = {};
    subitems['list-map'] = { text: '地図表示', icon: 'map', iconsx: null, onClick: () => navigate(Routing.StadiumMap.path), subitems: {} };
    subitems['list-table'] = { text: '一覧表示', icon: 'table', iconsx: null, onClick: () => navigate(Routing.Stadium.path), subitems: {} };
    
    setMenuItems((menuItems) => addSubItems(menuItems, 'stadium', subitems));
  }, [latLon]);

  return (
    <>
      <StadiumDetail
        stadiumDetailStatus={stadiumDetailStatus}
        onToggleDrawer={handleToggleDrawer}
      />
      <APIProvider apiKey={import.meta.env.VITE_GOOGLE_MAPS_API_KEY}>
        <Map id="m1"
          style={{margin: '0 auto', width: '100%', height: '90vh'}}
          defaultCenter={{lat: 35.4694, lng: 139.603808}}
          defaultZoom={10}
          gestureHandling={'greedy'}
          disableDefaultUI={true}
          onIdle={handleIdle}
        >
          <Suspense fallback={<div style={{position: 'absolute', top: '0px', display: 'flex', justifyContent: 'center', alignItems: 'center', width: '100%', height: '100%'}}><CircularProgress /></div>}>
            <StadiumMapMarker latLon={latLon} onToggleDrawer={handleToggleDrawer}/>
          </Suspense>
        </Map>
      </APIProvider>
    </>
  );
 };
features/stadium/stadium-list/components/StadiumMapMarker.tsx
import { useEffect, useState } from 'react';
import { getStadiumsByLatLon } from "@/src/infrastructures/api/stadiumApi";
import { Marker } from '@vis.gl/react-google-maps';
import { wrapPromise } from '@/src/components/logics/wrapPromise'

type Props = {
  latLon: any;
  onToggleDrawer: (stadiumName: string) => void;
};

const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));// テスト用
export const StadiumMapMarker = (props: Props) => {
  const [records, setRecords] = useState([{"id": 0, team_name: "", stadium_name: "", latitude: 0, longitude: 0, capacity: 0} as Stadium]);

  const getStadiumData = async (): Promise<any> => {
    await sleep(3000);// テスト用
    return await getStadiumsByLatLon(props.latLon.lat, props.latLon.lon, 300);
  }

  useEffect(() => {
    try {
      const stadiums = wrapPromise(getStadiumData())
      setRecords(stadiums.read)
    } catch {
      console.log("error")
    };
  }, [props.latLon]);

  return (
    <>
    {records.map((record) => {
      return (
        <Marker
          position={{lat: record.latitude??0, lng: record.longitude??0}}
          clickable={true}
          onClick={() => props.onToggleDrawer(record.stadium_name)}
          title={record.stadium_name}
        />);
    })}
    </>
  );
 };

とりあえず完了。

ユーザー権限によるメニュー制御

ログインユーザーによってメニューを制御する。これもよくある。もちろんサーバー側が関与する場合はサーバー側でも制御することになる。新しいユーザー user2 を作ってユーザー専用メニューデータも作る。値が 1 の場合は権限あり、0 の場合は表示するけど無効化、そもそもデータがない場合は表示すらしないパターン。

testdb/db.json
    "users": [
・・・
        {
            "id": "user2",
            "password": "test2",
            "name": "テストユーザー2"
        }
    ],
・・・
    "auth-menus": [
        {"user_id":"test",
         "auth":[
            {"id":"stadium","value":1},{"id":"list-map","value":1},{"id":"list-table","value":1},
            {"id":"task","value":1},{"id":"list-all","value":1},{"id":"list-unchecked","value":1},{"id":"list-checked","value":1},{"id":"list-removed","value":1},
            {"id":"share","value":1},
            {"id":"logout","value":1}
        ]},
        {"user_id":"user2",
         "auth":[
            {"id":"task","value":1},{"id":"list-all","value":1},{"id":"list-unchecked","value":1},{"id":"list-checked","value":1},{"id":"list-removed","value":1},
            {"id":"share","value":0},
            {"id":"logout","value":1}
        ]}
    ]
infrastructures/api/authMenuApi.ts
import { fetchWithErrorHandling } from "./_base";

export const getAuthMenus = async (userId: string) => {
  return await fetchWithErrorHandling<[{"user_id": string, "auth": [{"id": string, "value": number}]}]>(`${import.meta.env.VITE_BASE_URL}/auth-menus/?q=` + userId, {
    method: 'GET',
  });
};

ユーザーごとの有効メニューはログイン時に取得して、サイドメニュー作成時に使用するから状態保持ファイルを作成する。

stores/menuItem/authMenuState.ts
import { atom } from "recoil";

export const authMenuState = atom({
 key: 'authMenuState', 
 default: {} as {"id": string, "value": number}[],
});
features/login/login-form/components/Login.tsx
・・・
import { authMenuState } from "@/src/stores/menuItem/authMenuState";
import { getAuthMenus } from "@/src/infrastructures/api/authMenuApi";
・・・
export const Login = memo(() => {
・・・
  const [authMenu, setAuthMenu] = useRecoilState(authMenuState);
・・・
        const user = await authUserByIdAndPassword(userId, password);
        setAccount({id: user.id, name: user.name});

        const menus = await getAuthMenus(user.id);
        setAuthMenu(menus[0]["auth"]);

        navigate(Routing.Todo.path);
・・・

Props 経由で有効メニューを共通レイアウトからサイドメニューに渡す。

components/composite/AuthLayout.tsx
・・・
import { authMenuState } from "@/src/stores/menuItem/authMenuState";
・・・
  const [authMenu, setAuthMenu] = useRecoilState(authMenuState);
・・・
      <SideBar
・・・
        authMenu={authMenu}
      />
features/sidebar/sidebar-list/components/SideBar.tsx
・・・
import { authMenuState } from "@/src/stores/menuItem/authMenuState";
・・・
type Props = {
・・・
  authMenu:{"id": string, "value": number}[]
};
・・・
  Object.keys(props.menuItems).forEach((itemId, idx) => {
    const target = props.authMenu && props.authMenu.find((menu: {"id": string, "value": number}) => menu.id == itemId);
    if (!target) {
      return;
    }
・・・
<ListItemButton
        disabled={target.value !== 1}
・・・
    Object.keys(props.menuItems[itemId].subitems).forEach((subItemId: string, subIdx) => {
      const subTarget = props.authMenu && props.authMenu.find((menu: {"id": string, "value": number}) => menu.id == subItemId);
      if (!subTarget) {
        return;
      }
・・・
          <ListItemButton
            aria-label={subItemId}
            disabled={subTarget.value !== 1}
            selected={subItemId === props.operateMenu.selectMenuId}
・・・
export const SideBar = (props: Props) => {
・・・
  const [authMenu, setAuthMenu] = useRecoilState(authMenuState);
・・・

user2 はスタジアムメニューがなく、アプリ共有メニューが無効化できた。

リロード対応

やっぱりブラウザリロード対応も。cookie によるセッション管理ではなく、トークンによる認証を前提として、IndexedDB にユーザーID、有効期限を保持することとする。通常はそれらに加え、アクセストークンなんかも保持しといていいのかね?リフレッシュトークンはどうすんだ?理解うすいな、

features/login/login-form/components/Login.tsx
・・・
import { memo, useState, useEffect } from "react";
import * as localforage from 'localforage';
・・・
  useEffect(() => {
    localforage
    .getItem<{id: string, name: string, limit: Date}>('todo-account')
    .then(value => {
      if (value) {
        setAccount({id: value.id, name: value.name});
        auth({id: value.id, limit: value.limit});
      }
    });
  }, []);
・・・
  const auth = async (target:{id: string, limit: Date|undefined} = {id: "", limit: undefined}) => {
    try {
      let user;
      if (!target.id && userId && password) {
        user = await authUserByIdAndPassword(userId, password);
        setAccount({id: user.id, name: user.name});

        await localforage
        .setItem('todo-account', 
          {id: user.id, name: user.name, limit: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000)}
        );
      } else if (target.id) {
        const now = new Date();
        if (target.limit! >= now) {
          user = {id: target.id}
        }
      }

      if (user) {
        const menus = await getAuthMenus(user.id);
        setAuthMenu(menus[0]["auth"]);

        navigate(Routing.Todo.path);
      }
・・・

雑感

よし、とりあえず react 基本は今度こそ終了とする。