フロントエンド側 React サブメニュー/ログイン/Recoil

2024-11-08

冷めないうちにパクリ27発目!!
昨日のメシが思い出せない今日この頃。

パクリ元

内容

React界隈を検索していると気になってくるのが Next.js というワード。するとパクリ元1つ目みたいな記事があったりして読んでみてもいまいち理解できず。

Next.jsは、ReactベースのJavaScriptのフレームワークで、サーバサイドレンダリング・静的サイト作成・APIルーティングなどの機能を提供しています。
Viteは、Vue.jsやReactおよびそのほかのフロントエンドフレームワーク用の高速でモダンなビルドツールです。

とあり、全然違うや~ん、違うもの比較してどうなのよ。一方は実行環境で、一方は開発環境で、というように読めるんだけど理解が足りてないのかのー。
パクリ元2つ目にもあるように、Next.jsでもビルドツールとしてViteを使用する、という意味だと捉えてる、なんとなく。

Next.js に進むのはまだ早い気がするので、とりあえず前回のをなんとなくのWEBアプリみたいなのに改造していくこととする。どうしても業務システムからの目線で見ちゃうからそういう改造を。

  • PCでのメニューは常時表示    ←←← 今回ココから。
  • メニュー内のサブメニュー表示
  • ログイン/ログアウト
  • 最適なフォルダ構成
  • 外部API呼び出し
  • 一覧表示/地図表示/詳細表示
  • 検索
  • ダイアログ切り離し
  • 共通レイアウト
  • メニューの作り方をなんとかしたい
  • リンクの文字列をなんとかしたい

PCでのメニューは常時表示

PCでのハンバーガーメニュー表示って狂ってると思ってるから、パクリ元3つ目、4つ目を参考にやってみる。

ToolBar.tsx
・・・
export const headerHeight = '56px';  // 追加
export const ToolBar = (props: Props) => (
  <Box sx={{ flexGrow: 1 }}>
    <AppBar position="static">
      <Toolbar variant="dense" sx={{ minHeight: headerHeight }}>  // 変更
          aria-label="menu-button"
          size="large"
          edge="start"
          color="inherit"
          sx={{ mr: 2 ,display: { xs: 'block', sm: 'none' }}} // 変更:xsまで(スマホ)はハンバーガーメニューアイコンを表示
SideBar.tsx
・・・
import { headerHeight } from './ToolBar';  // 追加
・・・
// SideBar のメニュー部分を外出し
const drawer = (props: Props) => (
  <>
  <DrawerList role="presentation" onClick={props.onToggleDrawer}>
    <DrawerHeader>
      ・・・
    </List>
  </DrawerList>
  </>
);
・・・
export const SideBar = (props: Props) => (
  <>
  <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>
  </>
);
TodoItem.tsx
・・・
  return (
    <Container sx={{pl: {xs: 0, sm: '250px'}}}>

メニュー、ヘッダ、TODO全てに同じような条件を入れてしまったが、、こういう場合はウィンドウサイズ変更をトリガーに動かすべきか。まあいいや。

メニュー内のサブメニュー表示

パクリ元5つ目を参考にサブメニュー作成。

~/vite-project$ npm install @mui/icons-material
App.tsx
・・・
   const [drawerOpen, setDrawerOpen] = useState(false);
   const [menuOpen, setMenuOpen] = useState(false); // 追加
   const handleToggleMenu = () => { // 追加
     setMenuOpen(!menuOpen);
   };
・・・
      <SideBar
        menuOpen={menuOpen} // 追加
        onToggleMenu={handleToggleMenu} // 追加
        ・・・
      />
SideBar.tsx
・・・
import Collapse from '@mui/material/Collapse'; // 追加
import ExpandLess from '@mui/icons-material/ExpandLess'; // 追加
import ExpandMore from '@mui/icons-material/ExpandMore'; // 追加
import TaskIcon from '@mui/icons-material/Task'; // 追加

type Props = {
  menuOpen: boolean; // 追加
  onToggleMenu: () => void; // 追加
  ・・・
};
・・・
       // 親アイテム追加、今までのList配下を全てCollapse配下へ移動して子アイテム化
      <ListItemButton onClick={props.onToggleMenu}>
        <ListItemIcon>
          <TaskIcon />
        </ListItemIcon>
        <ListItemText primary="タスク" />
        {props.menuOpen ? <ExpandLess /> : <ExpandMore />}
      </ListItemButton>
      <Collapse in={props.menuOpen} timeout="auto" unmountOnExit>
        <List>
          ・・・ // List配下を全てCollapseタグで囲む
        </List>
      </Collapse>

サブメニューは階層一つだけで、クリックしたメニューの選択化も合わせて汎用化してみる。

MenuItem.d.ts
declare type MenuItem = {
    id: string;             // メニューのID
    text: string;           // テキスト
    icon: string;           // アイコン名
    iconsx: SxProps<Theme> | undefined; // アイコンのスタイル
    onClick: Function | MouseEventHandler<HTMLDivElement>;  // メニュー押下時の処理
    subitems: MenuItem[];   // サブアイテム
};
App.tsx
・・・
import { lightBlue } from '@mui/material/colors';
・・・
  const [selectMenuId, setSelectMenuId] = useState('');
  const [openMenuId, setOpenMenuId] = useState('');
  const [openMenu, setOpenMenu] = useState(false);
  const handleToggleMenu = (id: string) => {
    if (openMenuId == id) {
      setOpenMenu((openMenu) => !openMenu);
    } else {
      setOpenMenu(true);
    }
    setOpenMenuId(id);
  };
・・・
  const handleSort = (filter: Filter, id: string) => {
    setSelectMenuId(id);
    setFilter(filter);
  };
・・・
  const menuItems: MenuItem[] = [
    {id: 'task', text: 'タスク', icon: 'task', iconsx: null, onClick:  () => handleToggleMenu('task'), subitems:
      [
        {id: 'list-all', text: 'すべてのタスク', icon: 'subject', iconsx: null, onClick: () => handleSort('all', 'list-all'), subitems: []},
        {id: 'list-unchecked', text: '現在のタスク', icon: 'radio_button_unchecked', iconsx: { color: lightBlue[500] }, onClick: () => handleSort('unchecked', 'list-unchecked'), subitems: []},
        {id: 'list-checked', text: '完了したタスク', icon: 'check_circle_outline', iconsx: { color: pink.A200 }, onClick: () => handleSort('checked', 'list-checked'), subitems: []},
        {id: 'list-removed', text: 'ごみ箱', icon: 'delete', iconsx: null, onClick: () => handleSort('removed', 'list-removed'), subitems: []},
      ]
    },
    {id: 'share', text: 'このアプリを共有', icon: 'share', iconsx: null, onClick:  handleToggleQR, subitems: []},
  ];
・・・
  return (
・・・
      <SideBar
        drawerOpen={drawerOpen}
        openMenuId={openMenuId}
        openMenu={openMenu}
        menuItems={menuItems}
        selectMenuId={selectMenuId}
      />
・・・
SideBar.tsx
・・・
 type Props = {
   drawerOpen: boolean;
   onToggleDrawer: () => void;
   openMenuId: string;
   openMenu: boolean;
   menuItems: MenuItem[];
   selectMenuId: string;
 };
・・・
const drawer = (props: Props) => {
  let ret: JSX.Element[] = [];
  props.menuItems.forEach((item, idx) => {
    if (idx > 0) {
      ret.push(<Divider />);
    }
    ret.push(
      <ListItemButton selected={item.id === props.selectMenuId} onClick={item.onClick}>
        <ListItemIcon>
          <Icon sx={item.iconsx}>{item.icon}</Icon>
        </ListItemIcon>
        <ListItemText primary={item.text} />
        {(item.subitems.length === 0) ? null: ( props.openMenuId === item.id ? (props.openMenu ? <ExpandLess /> : <ExpandMore />) : <ExpandMore />)}
      </ListItemButton>
    );

    let sub: JSX.Element[] = [];
    if (Array.isArray(item.subitems)) {
      item.subitems.forEach((subitem: MenuItem) => {
        sub.push(
          <ListItem disablePadding>
            <ListItemButton
              aria-label={subitem.id}
              selected={subitem.id === props.selectMenuId}
              onClick={subitem.onClick}
            >
              <ListItemIcon>
                <Icon sx={subitem.iconsx}>{subitem.icon}</Icon>
              </ListItemIcon>
              <ListItemText secondary={subitem.text} />
            </ListItemButton>
          </ListItem>
        );
      });
      ret.push(
        <Collapse in={props.openMenuId === item.id && props.openMenu} timeout="auto" unmountOnExit>
          <List sx={{paddingLeft: 2}}>
            {sub}
          </List>
        </Collapse>
      );
    }
  });

  return (
  <>
    <DrawerList role="presentation" onClick={props.onToggleDrawer}>
      <DrawerHeader>
        <DrawerAvatar>
          <Icon>create</Icon>
        </DrawerAvatar>
        <p>TODO v{pjson.version}</p>
      </DrawerHeader>
        {ret}
    </DrawerList>
  </>)
};
・・・

これでユーザーの権限等でメニューが変わる事にも対応できる。

ログイン/ログアウト

ログイン不要なページからダイアログでログイン画面出して入るパターンもあるけど、遷移についてやりたいので今回はログイン画面→TODO画面への流れを前提にパクリ元6つ目を参考にする。

~/vite-project$ npm install react-router-dom

Routerというモジュールを作成してそこから Login や App を呼ぶように一枚カマす。作成するフォルダはとりあえず全てsrc 配下とする。

main.tsx
・・・
import { BrowserRouter } from "react-router-dom";
import { Router } from "./Router";
createRoot(document.getElementById('root') as Element).render(
  <StrictMode>
    <BrowserRouter>
      <Router />
    </BrowserRouter>
  </StrictMode>,
);

registerSW();
Router.tsx
import { Login } from "./Login";
import { App } from './App';

export const Router = () => {
  return (
    <Routes>
      <Route path="/login" element={<Login />} />
      <Route path="/app" element={<App />} />
    </Routes>
  );
};
Login.tsx
import {
  Box,
  Button,
  Card,
  CardActions,
  CardContent,
  CardHeader,
  TextField,
} from "@mui/material";
import { memo, useState } from "react";
import { useNavigate } from "react-router-dom";

export const Login = () => {
  const [userId, setUserId] = useState("");
  const [password, setPassword] = useState("");
  const navigate = useNavigate();

  const cardStyle = {
    display: "block",
    transitionDuration: "0.3s",
    height: "450px",
    width: "400px",
    variant: "outlined",
  };

  const onClickLogin = () => {
    if (userId === 'test' && password === 'test1') {
      navigate("/app");
    } else {
      navigate("/login");
      console.log("failed");
    }
  };

  return (
    <Box
    display="flex"
    alignItems="center"
    justifyContent="center"
    padding={20}
  >
    <Card style={cardStyle}>
      <CardHeader title="ログインページ" />
      <CardContent>
        <div>
          <TextField
            fullWidth
            id="username"
            type="email"
            label="Username"
            placeholder="Username"
            margin="normal"
            onChange={(e) => setUserId(e.target.value)}
          />
          <TextField
            fullWidth
            id="password"
            type="password"
            label="Password"
            placeholder="Password"
            margin="normal"
            onChange={(e) => setPassword(e.target.value)}
          />
        </div>
      </CardContent>
      <CardActions>
        <Button
          variant="contained"
          size="large"
          color="secondary"
          onClick={onClickLogin}
        >
          Login
        </Button>
      </CardActions>
    </Card>
  </Box>
  );
};

/login にアクセスして test/test1 を入力して LOGIN ボタンを押すことで /app に遷移することを確認。ログイン画面なんて人によって変わらないから、memo() というのでキャッシュすることを確認。

次にパクリ元7つ目を参考に以下を確認。

  • ドメインのみの場合 ログイン画面へ遷移
  • 不明なアドレスの場合404エラー
ErrorPage.tsx
import {
  Container,
  Typography,
} from "@mui/material";
import { Link } from "react-router-dom";

type Props = {
  title: string;
  description: string;
};

export const ErrorPage = (props: Props) => {
  return (
    <Container sx={{padding: 20}}>
      <Container>
        <Typography variant="h3" textAlign="center">{ props.title }</Typography>
        <br />
        <Typography variant="h6" textAlign="center">{ props.description }</Typography>
        <br />
        <Typography variant="h6" textAlign="center">
          <Link to="/login">ログインページに戻る</Link>
         </Typography>
      </Container>
    </Container>
  )
}

export const ErrorPage404 = () => {
  const props: Props = {
    title: '404 NOT FOUND',
    description: 'お探しのページが見つかりませんでした。',
  }
  return ErrorPage(props);
}
Router.tsx
・・・
    <Routes>
      <Route path="/" element={<Login />} />
      <Route path="/login" element={<Login />} />
      <Route path="/app" element={<App />} />
      <Route path="*" element={<ErrorPage404 />} />
    </Routes>
・・・

次にログインに失敗した場合のダイアログ表示に対応する。前回の AlertDialog を汎用化してみる。パクリ元8つ目を参考に、エレメント表示の条件分岐として以下を覚える。

{props.hoge &&
  <Button>ボタン</Button>
}
MessageDialog.tsx
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 = {
  dialogTitle: string;
  dialogMessages: string[];
  dialogOpen: boolean;
  onToggleDialog: () => void;
  onOkFunc: () => void;
  okCaption: string;
  onCancelFunc: () => void;
  cancelCaption: string;
  isOkAutoFocus: boolean;
};

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

export const MessageDialog = (props: Props) => {
  let dialogMessages: JSX.Element[] = [];
  props.dialogMessages.forEach((message, idx) => {
    dialogMessages.push(<DialogContentText>{message}</DialogContentText>);
  });

  return (
    <Alert open={props.dialogOpen} onClose={props.onToggleDialog}>
      <DialogTitle>{props.dialogTitle}</DialogTitle>
      <DialogContent>
        {dialogMessages}
      </DialogContent>
      <DialogActions>
        {props.cancelCaption !== '' && <Button
          aria-label="alert-cancel"
          onClick={() => {
            props.onCancelFunc();
          }}
          color="primary"
          autoFocus={!props.isOkAutoFocus}
        >
          {props.cancelCaption}
        </Button>}
        <Button
          aria-label="alert-ok"
          onClick={() => {
            props.onOkFunc();
          }}
          color="secondary"
          autoFocus={props.isOkAutoFocus}
        >
          {props.okCaption}
        </Button>
      </DialogActions>
    </Alert>
  )
};
Login.tsx
・・・
import { MessageDialog } from "./MessageDialog";
・・・
  const onClickLogin = () => {
    if (userId === 'test' && password === 'test1') {
      navigate("/app");
    } else {
      handleDialogSetting(
        'ログイン失敗',
        ['ログインに失敗しました。'],
        'OK',
        '',
        true
      );
      handleToggleDialog();
    }
  };
  const [dialogTitle, setDialogTitle] = useState('');
  const [dialogMessages, setDialogMessages] = useState(['']);
  const [okCaption, setOkCaption] = useState('');
  const [cancelCaption, setCancelCaption] = useState('');
  const [isOkAutoFocus, setIsOkAutoFocus] = useState(true);
  const handleDialogSetting = (
    dialogTitle: string, dialogMessages: string[],
    okCaption: string, cancelCaption: string,
    isOkAutoFocus: boolean) => {

    setDialogTitle(dialogTitle);
    setDialogMessages(dialogMessages);
    setOkCaption(okCaption);
    setCancelCaption(cancelCaption);
    setIsOkAutoFocus(isOkAutoFocus);
  };
  const [dialogOpen, setDialogOpen] = useState(false);
  const handleToggleDialog = () => {
    setDialogOpen((dialogOpen) => !dialogOpen);
  };

  const handleOk = () => {
    handleToggleDialog();
  };
  const handleCancel = () => {
    handleToggleDialog();
  };
・・・
  return (
    <>
    <MessageDialog
      dialogTitle={dialogTitle}
      dialogMessages={dialogMessages}
      dialogOpen={dialogOpen}
      onToggleDialog={handleToggleDialog}
      onOkFunc={handleOk}
      okCaption={okCaption}
      onCancelFunc={handleCancel}
      cancelCaption={cancelCaption}
      isOkAutoFocus={isOkAutoFocus}
    />
    <Box
・・・
    </>
  );
});

MessageDialog 今回ログインに入れたけどどこでも使いたいから修正する必要がありそう、だがいずれ。

ログイン処理はまだまだある。次はログインした場合、ユーザー名を表示したい。ログイン時に取得したユーザーID/ユーザー名を App に渡してもイイが、App 以外のコンポーネントが追加された場合使えないのもおかしいのでどこかでグローバル変数みたいに保持しておきたい。パクリ元9個目に従い、Recoil を利用する。

~/vite-project$ npm install recoil
main.tsx
・・・
import { RecoilRoot } from 'recoil'
・・・
  <StrictMode>
    <RecoilRoot>
      <BrowserRouter>
        <Router />
      </BrowserRouter>
    </RecoilRoot>
  </StrictMode>,
・・・
accountState.tsx
import { atom } from "recoil";

export const accountState = atom({
 key: 'accountState', 
 default: {id: '', name: ''},
});
Login.tsx
・・・
import { useRecoilState } from "recoil";
import { accountState } from "./accountState";
・・・
const [account, setAccount] = useRecoilState(accountState)
・・・
     if (userId === 'test' && password === 'test1') {
       setAccount({id: 'test', name: 'テストユーザー'});
       navigate("/app");
SideBar.tsx
・・・
import { useRecoilState } from "recoil";
import { accountState } from "./accountState";
・・・
   const [account, setAccount] = useRecoilState(accountState)
・・・
         <p>TODO v{pjson.version}</p>
         <p>{account.name}様</p>
・・・

ついでにログアウトメニューも追加。

App.tsx
・・・
import { useRecoilState, useResetRecoilState } from "recoil";
import { accountState } from "./accountState";
import { useNavigate } from 'react-router-dom';
・・・
   const resetAccountState = useResetRecoilState(accountState)
   const navigate = useNavigate();
・・・
  const menuItems: MenuItem[] = [
・・・
    {id: 'logout', text: 'ログアウト', icon: 'logout', iconsx: null, onClick: () => { resetAccountState(); navigate("/login");}, subitems: []},
  ];
・・・

メニューを作る箇所(今回はApp.tsx(TODO表示箇所))でログアウト用関数(resetAccountState)を知る必要はないので今後要修正やね。せっかくここまでやったんだけどパクリ元10個目を見つけてしまった。Recoilは状態管理の選択肢ではなくなってしまった との事。useContext でカスタムフックがうまく作れなかったりRedux がイミフだったりして結構気に入ってたのにね。そのまま進めちゃうけど。
ログイン系としては最後に、ログインせずに要ログイン画面にアクセスした場合の制御をパクリ元11個目を元に行う。

Router.tsx
・・・
import { Route, Routes, Navigate, Outlet } from "react-router-dom";
import { useRecoilState } from "recoil";
import { accountState } from "./accountState";
・・・
const PrivateRoutes = () => {
  const [account, setAccount] = useRecoilState(accountState)
  if (account.id === '') {
    return (
      <Navigate to='/login' />
    )
  }
  return <Outlet />
}
export const Router = () => {
  return (
    <Routes>
      <Route path="/" element={<Login />} />
      <Route path="/login" element={<Login />} />
      <Route element={<PrivateRoutes />}>
        <Route path="/app" element={<App />} />
      </Route>
      <Route path="*" element={<ErrorPage404 />} />
    </Routes>
  );
};

とりあえずここで一旦終了。

ページリロードでログイン情報が消えてしまう件は、IndexedDB で管理しておくのもイイが、パクリ元12個目のようにやはりサーバ側に一度投げるのが筋だろうと思う。

雑感

いや全然進まないもんだ。理解力の衰えもあるが。