【チーム開発】技術的に大変だったこと

【チーム開発】技術的に大変だったこと

投稿日: 2024年11月21日

学習振り返り
要約
  • モーダルの実装において、複数ページからのアクセスに対応するためのエンドポイント不足が発覚し、追加対応を依頼した。
  • モーダルの開閉状態を適切に管理するため、コンポーネントの配置を見直し、問題を解決したが、当日は焦りがあった。
  • こうすけさんとの協力によって無事に納品でき、チーム連携の重要性を再確認した。

はじめに

昼間の時点では次はImageコンポーネントについて書こうと思っていたんですけど一旦辞めました。

でもまた近いうちに書きます。

今回は一番大変だったモーダルについてです・・!

難易度爆上げポイント

パッと出てくるのは下記の2点です。

  1. モーダルを開く導線が他のページも含むこと(トップページ、画像ページからも投稿ページ上でモーダルを開く(そのページで開くなら簡単なのにw)

  2. 表示していない投稿もボタンクリックで表示できないといけないこと

考えたこと

いきなり裏話的なかんじですが、最初いただいたエンドポイントリスト見る限りモーダル表示してモーダル内で全投稿を表示させるのに必要な情報を、トップページとかでは持っていなくて(投稿ページにはある)、、あれ?データないのにどうやって??となったんです。

これは技術不足云々ではなくて確認事項だなと思って、色々スケジュール合わずこうすけさんに説明して確認していただいたところ、やはりエンドポイントが不足していたとのことで(これはめっちゃホッとしたところですw)追加してもらって準備万端、戦闘態勢に入りました。

(ここ返信してなかった💦フロントでやります大丈夫です◎)

この「エンドポイント足りなくはないですか?」とか、「レスポンスに●●が必要なのにないです」とか、他の実務案件含めて実際結構あるんですけど私の間違いだったらいけないので確認するのに結構勇気が要ります。。

他にもできなくはないけどここの型が一緒だったらPropsもっとシンプルになるのに違う・・でもできなくはないからと実装したらレビューでまとめて渡せと言われて、したいけど型が違うから出来ない旨伝えるとバックエンド変更になったこともありました。

レスポンスがこうだったらもっとスッキリかけるのにということは一回相談していいとおもいました。(これも大きな学びです)

そんなこんなで、モーダルで表示する投稿は都度取得する形となりました。

動的に取得する必要があるので、全fetch処理内でここだけSSRではないです。

クライアントサイドで行うfetchにはswrを使いました。

すべての投稿データはバケツリレーでせっせと渡す形です!

実際のコード

公開OKと言われていますので大丈夫ですが長いです。

"use client";
import {
  faChevronRight,
  faChevronLeft,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useState, useEffect } from "react";
import { Modal } from "@/app/_components/Modal";
import { ModalArticleContent } from "@/app/_components/ModalArticleContent";
import { Article } from "@/app/_types/Article";

interface Props {
  isOpen: boolean;
  setIsOpen: (isOpen: boolean) => void;
  articles: Array<Article & { [key: string]: any }>;
  articleId: string | null;
  startId: string | null;
}

export const ArticleModal: React.FC<Props> = ({
  isOpen,
  setIsOpen,
  articles,
  articleId,
  startId,
}) => {
  const [articleIndex, setArticleIndex] = useState<number | null>(null);
  const [fileId, setFileId] = useState<number | null>(null);

  // 親から受け取ったarticleIdとstartIdを使って初期化
  useEffect(() => {
    if (!articleId) return;
    setArticleIndex(
      articles.findIndex((article) => article.id === parseInt(articleId, 10))
    );
    setFileId(startId ? parseInt(startId, 10) : 0);
  }, [articles, articleId, startId]);

  useEffect(() => {
    if (articleIndex === null) return;
    setIsOpen(true);
  }, [articleIndex, setIsOpen]);

  const onClose = (event: React.MouseEvent<HTMLElement>) => {
    event.stopPropagation();

    // クエリパラメータを削除してURLを更新
    const params = new URLSearchParams(window.location.search);
    params.delete("start_id");
    params.delete("article_id");
    const newUrl = `${window.location.pathname}?${params.toString()}`;
    window.history.replaceState({}, "", newUrl);

    setIsOpen(false);
  };

  const updateUrl = (newArticleIndex: number) => {
    const params = new URLSearchParams(window.location.search);
    params.set("article_id", articles[newArticleIndex].id.toString());
    params.set("start_id", "");
    const newUrl = `${window.location.pathname}?${params.toString()}`;
    window.history.replaceState({}, "", newUrl);
  };

  const nextArticle = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.stopPropagation();
    if (articleIndex === null) return;
    const newArticleIndex = (articleIndex + 1) % articles.length;
    setArticleIndex(newArticleIndex);
    updateUrl(newArticleIndex);
  };

  const prevArticle = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.stopPropagation();
    if (articleIndex === null) return;
    const newArticleIndex =
      (articleIndex - 1 + articles.length) % articles.length;
    setArticleIndex(newArticleIndex);
    updateUrl(newArticleIndex);
  };
  if (articleIndex === null) return;

  return (
    <Modal isOpen={isOpen} onClose={onClose}>
      <div className="z-50 flex items-center justify-between gap-[150px]">
        <button className="p-3" onClick={prevArticle} type="button">
          <FontAwesomeIcon
            className="text-[34px] text-[#ACAAA9]"
            icon={faChevronLeft}
          />
        </button>
        <div
          className="h-[600px] w-[800px] rounded-2xl bg-white p-8"
          onClick={(e) => e.stopPropagation()}
        >
          <ModalArticleContent
            articleIndex={articleIndex}
            fileId={fileId}
            articles={articles}
          />
        </div>
        <button className="p-3" onClick={nextArticle} type="button">
          <FontAwesomeIcon
            className="text-[34px] text-[#ACAAA9]"
            icon={faChevronRight}
          />
        </button>
      </div>
    </Modal>
  );
};

モーダルの開閉(isOpen)はホバーとの関係上もっと祖先のコンポーネントで定義しています。(どこじゃないといけないかは後述)

ここも実は苦労ポイントなんです。

モーダル開いてるのに画面からカーソルが出ていくと意図せずモーダル閉じる現象が発生していて、要素の確認とかスタイルを開発者ツールで全然見れなくて・・

原因確認しているとホバー外れるを検知するイベントは(onMouseLeave)モーダルのしたでも発火することがわかりました。(恐らくバブリングした結果ですね・・)

つまり、ホバーの状態管理以上親のコンポーネントでモーダルの開閉状態の管理が必要で、ホバーの状態を変えるのはisOpenがfalseの時のみという条件が必要だったことが、「ギャーなんでモーダル閉じるんよー」って言いながらコンソールに各関数発火わかるようにコンソールに出力してみてわかりました。

デバックできなくてめっちゃくちゃストレスのある意図しない挙動でした・・

中に表示しているデータの内容はこちら。

ここでデータの取得を行っています。

"use client";
import {
  faChevronRight,
  faChevronLeft,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Navigation } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import { useAricleDetail } from "../_hooks/useAricleDetail";
import { Article } from "../_types/Article";
import { Influencer } from "./Influencer";
import { MagazineLink } from "./MagazineLink";
import { MediaRenderer } from "./MediaRenderer";

import "swiper/css";
import "swiper/css/navigation";

interface Props {
  fileId: number | null;
  articleIndex: number;
  articles: Array<Article & { [key: string]: any }>;
}
export const ModalArticleContent: React.FC<Props> = ({
  fileId,
  articleIndex,
  articles,
}) => {
  const { data, error, isLoading } = useAricleDetail(
    fileId,
    articles[articleIndex].id
  );
  if (isLoading) return <div>読込み中...</div>;
  if (error) return <div>{`エラーが発生しました ${error.message}`}</div>;
  if (!data) return <div>記事データがみつかりませんでした</div>;
  const firstSlideIndex = data.articleFiles.findIndex(
    articleFile => articleFile.id === fileId
  );
  return (
    <div className="flex h-[536px] gap-6">
      <div className="group h-full w-[402px]">
        <Swiper
          centeredSlides
          modules={[Navigation]}
          slidesPerView={1}
          navigation={{
            prevEl: "#button_prev",
            nextEl: "#button_next",
          }}
          initialSlide={firstSlideIndex}
          className=""
          loop
        >
          {data.articleFiles.map(item => {
            return (
              <SwiperSlide key={item.id}>
                <div className="relative h-[536px] w-[402px]">
                  <MediaRenderer imageUrl={item.imageUrl} autoPlay />
                </div>
              </SwiperSlide>
            );
          })}
          <button
            type="button"
            id="button_prev"
            className="invisible absolute left-5 top-1/2 z-[999] flex size-10 -translate-y-1/2 items-center justify-center rounded-full bg-white/50 opacity-0 transition-opacity duration-300 group-hover:visible group-hover:opacity-100"
          >
            <FontAwesomeIcon
              className="text-2xl text-[#ACAAA9] "
              icon={faChevronLeft}
            />
          </button>
          <button
            type="button"
            id="button_next"
            className="invisible absolute right-5 top-1/2 z-[999] flex size-10 -translate-y-1/2 items-center justify-center rounded-full bg-white/50 opacity-0 transition-opacity duration-300 group-hover:visible group-hover:opacity-100"
          >
            <FontAwesomeIcon
              className="text-2xl text-[#ACAAA9] "
              icon={faChevronRight}
            />
          </button>
        </Swiper>
      </div>
      <div className="relative h-full w-[310px] pt-8">
        <div className="flex justify-between pb-4">
          <Influencer icon={data.influencer.icon} name={data.influencer.name} />
          <div>{data.publishedAt}</div>
        </div>
        <h2 className="line-clamp-[3] pb-2 text-[18px] font-bold">
          {data.title}
        </h2>
        <div className="mb-[36px] line-clamp-[8] text-[14px]">{data.body}</div>
        <div className="absolute bottom-8 right-0">
          <MagazineLink magazineUsername={data.influencer.magazineUsername} />
        </div>
      </div>
    </div>
  );
};

ここでデータ取得を行っているフック使っています!

このモーダルをどこでレンダリングするか

これがなかなか難しくて、ここはずいぶん前に終わったと思っていたのに納期日当日に発覚したバグで対応に本当に焦りました。

投稿ページ以外からモーダル開く処理すると画面は遷移するけどモーダル開かないことがある問題が発生したんです。

調査した結果、実際は開いているように見える箇所も含めて全部開いていなくて、開いた後カーソルがホバーイベントを検知した時にモーダル開いているということがわかりました。

その理由はArticleModalコンポーネントをホバーした時に表示する要素を含むコンポーネントに置いていたことです。

その時のコード

/**トップページでも使うコンポーネント */
"use client";
import { useState, useRef } from "react";
import { Article } from "../_types/Article";
import { ArticleData } from "../_types/ArticleFiles";
import { ArticleModal } from "./ArticleModal";
import { HoverPreview } from "./HoverPreview";
import { MediaRenderer } from "./MediaRenderer";
interface Props {
  articleFileId: number;
  article: ArticleData;
  gridAspect: string;
  /** Articleを含むオブジェクト配列*/
  articles: Array<Article & { [key: string]: any }>;
}

export const MediaDisplay: React.FC<Props> = ({
  articleFileId,
  gridAspect,
  article,
  articles,
}) => {
  const [isHover, setIsHover] = useState(false);
  const [isOpen, setIsOpen] = useState(false);
  const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);

  const openHover = () => {
    if (isOpen) return;
    hoverTimeoutRef.current = setTimeout(() => {
      setIsHover(true);
    }, 300);
  };

  const closeHover = () => {
    if (isOpen) return;
    if (hoverTimeoutRef.current) {
      clearTimeout(hoverTimeoutRef.current);
    }
    setIsHover(false);
  };

  const imagewidth = gridAspect.split("[")[1].split("/")[0];
  return (
    <div
      className={`relative overflow-visible ${gridAspect}`}
      onMouseEnter={openHover}
      onMouseLeave={closeHover}
    >
      <MediaRenderer imageUrl={article.imageUrl} width={imagewidth} />
      {isHover && (
        <>
          <HoverPreview
            articleFileId={articleFileId}
            article={article}
            isHover={isHover}
            setIsHover={setIsHover}
            isOpen={isOpen}
            setIsOpen={setIsOpen}
          />
{/*ここが問題*/}
          <ArticleModal
            isOpen={isOpen}
            setIsOpen={setIsOpen}
            articles={articles}
            setIsHover={setIsHover}
          />          />
{/*ここが問題*/}
        </>
      )}
    </div>
  );
};

これは各ページに表示する共通コンポーネントのつもりで作ったものです。

トップページでも画像ページでも投稿ページでも、ホバーしたときに出てくる要素を含む画像のコンポーネントで、下記画像のホバー前の並んでいる画像を表示するコンポーネントです。

ここにモーダルのコンポーネントおいてたからおかしいことになっていました。

息子昼寝の時間2時間に全集中で開かない理由調査し、めっちゃ必死にコンソールに出力して何が起きているのか理解しようと努めました。。。

なにが起きているかわかった段階で息子もう起きるよねって時間だったので一旦辞めて(その1分後におきたw)あとは頭で考えました。

中略ですが、ここまでの調査内容と考えられる理由をこうすけさんに報告しようと思って私の考えていることを報告しました。

ここからはチームプレイですね。

神・・・!!

結果

問題だったこと

投稿ページ以外でレンダリングされる箇所にモーダルの開閉状態を持たせていたこと

で間違いなかったです。

改善策

投稿ページ上でレンダリングされるコンポーネントで開閉の状態管理を行えば問題なく動くようになりました・・!

最後はこうすけさんが実装してくださいました💦

アニメーションから逃げた私の担当範囲なのに・・感謝しかないです!!

余談

昼寝の間に調査した内容をこうすけさんに報告するも、予想外れてたら当日中無理じゃね?と思って。。

相当焦ってる私の様子

状況は説明しておこうと思って送りました。

すると・・・直後に・・!!

感謝感激でした。

ここからはトントンと進んで無事納品。ホッとしました💦

こんなん気付けばビール6缶空きますよね!!

関連記事

めちゃ端折ってクエリパラメータの操作についてのみ書いたのが下記の記事です。


あと、バブリングで苦しんだのもここです。
ボタンが入り組んでいましてバブリング祭りでした💦

知ってたらもっと早くできたのに(せっかちなので・・)って思いましたが、この悔しさの積み重ねが成長だと思うのでめげずに精進します!!


おわりに

説明雑感は否めないのですが、要所と私が焦っている様子をお伝えしました。

ここ関係するコンポーネントが結構あるので全体像をお伝えはしなかったのですが、本当に一番苦労した箇所でした💦

こうすけさんの協力あって納品が間に合ったのでほんとに良い感じに連携出来て有難いしかなかったです!!

ありがとうございました!!!!

シェア!

Threads
user
吉本茜
山口在住/二児の母/育休中
Loading...
記事一覧に戻る
Threads
0