【テスト駆動開発】Springboot & React - 第10回 : メニューバー のユーザー情報表示
1. はじめに
こんにちは、前回はプロフィール画像の修正を実装しました。
今回はメニューバー のユーザー情報を表示する機能を実装してみます。今日の実装完成の画面です。
2. 実装過程
2.1 メニューバー のユーザー情報表示
frontend/src/components/TopBar.spec.js
// テストケース: ユーザーがログインしている場合、displayNameが表示されることを確認
it('displays the displayName when user logged in', () => {
const { queryByText } = setup(loggedInState);
const displayName = queryByText('display1');
expect(displayName).toBeInTheDocument();
});
// テストケース: ユーザーがログインしている場合、ユーザーの画像が表示されることを確認
it('displays users image when user logged in', () => {
const { container } = setup(loggedInState);
const images = container.querySelectorAll('img');
const userImage = images[1];
// ユーザーの画像のURLにログイン状態の画像ファイル名が含まれていることを確認
expect(userImage.src).toContain('/images/profile/' + loggedInState.image);
});
ログイン状態の場合に正しく動作するかどうかを確認するためのものです。最初のテストは、displayNameが正しく表示されることを確認し、2番目のテストはユーザーの画像が正しいパスで表示されることを確認しています
frontend/src/components/TopBar.js
render() {
// ナビゲーションリンクの初期設定
let links = (
<ul className="nav navbar-nav ml-auto">
<li className="nav-item">
<Link to="/signup" className="nav-link">
Sign Up
</Link>
</li>
<li className="nav-item">
<Link to="/login" className="nav-link">
Login
</Link>
</li>
</ul>
);
// もしユーザーがログインしていれば、リンクを更新
if (this.props.user.isLoggedIn) {
links = (
<ul className="nav navbar-nav ml-auto">
<li className="nav-item nav-link">
<ProfileImageWithDefault
className="rounded-circle"
width="32"
height="32"
image={this.props.user.image}
/>
{this.props.user.displayName}
</li>
<li
className="nav-item nav-link"
onClick={this.onClickLogout}
style={{
cursor: 'pointer'
}}
>
Logout
</li>
<li className="nav-item">
<Link to={`/${this.props.user.username}`} className="nav-link">
My Profile
</Link>
</li>
</ul>
);
}
renderメソッドで、ユーザーがログインしているかどうかに基づいて異なるナビゲーションリンクを表示します。ユーザーがログインしている場合、サインアップとログインの代わりに、プロフィール画像、表示名、ログアウトボタン、およびプロフィールへのリンクが表示されます。
「 cursor: 'pointer'」でpointerは、通常はリンクやクリッカブルな要素に関連するデフォルトのカーソル形状。
2.2 ドロップダウンメニュー
frontend/src/components/TopBar.js
// ユーザーがログインしている場合のナビゲーションリンク
if (this.props.user.isLoggedIn) {
// ナビゲーションリンクを構築
links = (
<ul className="nav navbar-nav ml-auto">
<li className="nav-item dropdown">
{/* ユーザーネームとプロフィール画像の表示エリア */}
<div className="d-flex" style={{ cursor: 'pointer' }}>
<ProfileImageWithDefault
className="rounded-circle m-auto"
width="32"
height="32"
image={this.props.user.image}
/>
{/* ドロップダウンメニューを表示するトリガーとなるユーザーネーム */}
<span className="nav-link dropdown-toggle">
{this.props.user.displayName}
</span>
</div>
{/* ドロップダウンメニュー */}
<div className="p-0 shadow dropdown-menu">
{/* マイプロファイルへのリンク */}
<Link
to={`/${this.props.user.username}`}
className="dropdown-item"
>
<i className="fas fa-user text-info"></i> My Profile
</Link>
{/* ログアウトへのリンク */}
<span
className="dropdown-item"
onClick={this.onClickLogout}
style={{
cursor: 'pointer'
}}
>
<i className="fas fa-sign-out-alt text-danger"></i> Logout
</span>
</div>
</li>
</ul>
);
}
「ドロップダウン(dropdown)」が新しく追加られました。ドロップダウンメニューにはプロフィールへのリンクとログアウトボタンが含まれています。
2.3 ドロップダウンメニューの非表示処理
frontend/src/components/TopBar.js
// TopBarクラスコンポーネント
class TopBar extends React.Component {
// コンポーネントの初期状態
state = {
dropDownVisible: false // ドロップダウンメニューの表示状態
};
// コンポーネントがマウントされたときに呼ばれるライフサイクルメソッド
componentDidMount() {
document.addEventListener('click', this.onClickTracker); // ドキュメント全体に対してクリックイベントリスナーを追加
}
// コンポーネントがアンマウントされるときに呼ばれるライフサイクルメソッド
componentWillUnmount() {
document.removeEventListener('click', this.onClickTracker); // クリックイベントリスナーの削除
}
// クリックイベントをトラッキングするメソッド
onClickTracker = (event) => {
// actionAreaが存在し、かつクリックされた要素がactionArea外であれば、ドロップダウンを非表示にする
if (this.actionArea && !this.actionArea.contains(event.target)) {
this.setState({
dropDownVisible: false
});
}
};
// ユーザーネームをクリックしたときに呼ばれるメソッド
onClickDisplayName = () => {
this.setState({
dropDownVisible: true // ドロップダウンメニューを表示する
});
};
// ログアウトをクリックしたときに呼ばれるメソッド
onClickLogout = () => {
this.setState({
dropDownVisible: false // ドロップダウンメニューを非表示にする
});
const action = {
type: 'logout-success' // Reduxアクションの作成:ログアウト成功
};
this.props.dispatch(action); // アクションをディスパッチする
};
// My Profileをクリックしたときに呼ばれるメソッド
onClickMyProfile = () => {
this.setState({
dropDownVisible: false // ドロップダウンメニューを非表示にする
});
};
// actionAreaを設定するメソッド
assignActionArea = (area) => {
this.actionArea = area;
};
// レンダーメソッド
render() {
// レンダーするリンクの初期値
let links = (
<ul className="nav navbar-nav ml-auto">
<li className="nav-item">
<Link to="/signup" className="nav-link">
Sign Up
</Link>
</li>
<li className="nav-item">
<Link to="/login" className="nav-link">
Login
</Link>
</li>
</ul>
);
// ユーザーがログインしている場合のリンク
if (this.props.user.isLoggedIn) {
let dropDownClass = 'p-0 shadow dropdown-menu';
// ドロップダウンメニューが表示されている場合、'show'クラスを追加
if (this.state.dropDownVisible) {
dropDownClass += ' show';
}
// ユーザーがログインしている場合のリンクを更新
links = (
<ul className="nav navbar-nav ml-auto" ref={this.assignActionArea}>
<li className="nav-item dropdown">
{/* ユーザーネームをクリックしたときの表示 */}
<div
className="d-flex"
style={{ cursor: 'pointer' }}
onClick={this.onClickDisplayName}
>
<ProfileImageWithDefault
className="rounded-circle m-auto"
width="32"
height="32"
image={this.props.user.image}
/>
<span className="nav-link dropdown-toggle">
{this.props.user.displayName}
</span>
</div>
{/* ドロップダウンメニュー */}
<div className={dropDownClass} data-testid="drop-down-menu">
{/* My Profileへのリンク */}
<Link
to={`/${this.props.user.username}`}
className="dropdown-item"
onClick={this.onClickMyProfile}
>
<i className="fas fa-user text-info"></i> My Profile
</Link>
{/* ログアウトへのリンク */}
<span
className="dropdown-item"
onClick={this.onClickLogout}
style={{
cursor: 'pointer'
}}
>
<i className="fas fa-sign-out-alt text-danger"></i> Logout
</span>
</div>
</li>
</ul>
);
}
// レンダーされたリンクを返す
return links;
}
}
TopBarコンポーネントの初期状態(state)には、dropDownVisibleというプロパティがあり、これはドロップダウンメニューが表示されているかどうかを示します。
componentDidMountおよびcomponentWillUnmountライフサイクルメソッドでは、ドキュメント全体でクリックイベントを追跡し、クリックが行われたときにonClickTrackerメソッドを呼び出します。これにより、ドロップダウンメニューが開いている状態で画面の外をクリックした場合、メニューが閉じられます。
参考として、体表的ライフサイクルメソッドについてまとめます。componentDidMount()はコンポーネントがマウント(レンダリングが完了し、実際のDOMに挿入)された直後に呼び出されます。主に初期データの取得、外部ライブラリの初期化などに使用されます。componentWillUnmount()はコンポーネントがアンマウント(コンポーネントがDOMから削除)される直前に呼び出されます。主にリソースの解放やタイマーの解除などの処理に使用されます。
onClickTrackerメソッドは、クリックが行われた場所がthis.actionArea(アクションエリア)外である場合にdropDownVisibleをfalseに設定して、ドロップダウンメニューを閉じます。
onClickDisplayNameメソッドは、ユーザーの表示名がクリックされたときにドロップダウンメニューを表示するためにdropDownVisibleをtrueに設定します。
onClickLogoutメソッドは、ログアウトがクリックされたときにドロップダウンメニューを閉じ、ログアウトアクションをディスパッチ(dispatch)します。dispatchは、Reduxライブラリでアクションを送信するための関数です。dispatch関数は、アクションをストアに送り、それに基づいて新しい状態を生成します。アクションは、アプリケーション内の異なる部分で状態の変更をトリガーするために使用されます。
onClickMyProfileメソッドは、"My Profile"がクリックされたときにドロップダウンメニューを閉じます。
assignActionAreaメソッドは、アクションエリアへの参照を設定します。
renderメソッドでは、ユーザーがログインしている場合として、ユーザーがログインしていない場合として異なるナビゲーションリンクをレンダリングします。ログイン時には、ドロップダウンメニューが表示されるようになっています。
frontend/src/components/TopBar.spec.js
import * as authActions from '../redux/authActions';
…
// Reduxストアおよびテスト対象コンポーネントの設定
let store;
const setup = (state = defaultState) => {
store = createStore(authReducer, state);
return render(
<Provider store={store}>
<MemoryRouter>
…
describe('Interactions', () => {
it('displays the login and signup links when user clicks logout', () => {
const { queryByText } = setup(loggedInState);
const logoutLink = queryByText('Logout');
fireEvent.click(logoutLink);
const loginLink = queryByText('Login');
expect(loginLink).toBeInTheDocument();
});
// ユーザーネームがクリックされたときに、ドロップダウンメニューに 'show' クラスが追加されるかを確認
it('adds show class to drop down menu when clicking username', () => {
const { queryByText, queryByTestId } = setup(loggedInState);
const displayName = queryByText('display1');
fireEvent.click(displayName);
const dropDownMenu = queryByTestId('drop-down-menu');
expect(dropDownMenu).toHaveClass('show');
});
// アプリケーションのロゴがクリックされたときに、ドロップダウンメニューから 'show' クラスが削除されるかを確認
it('removes show class to drop down menu when clicking app log', () => {
const { queryByText, queryByTestId, container } = setup(loggedInState);
const displayName = queryByText('display1');
fireEvent.click(displayName);
const logo = container.querySelector('img');
fireEvent.click(logo);
const dropDownMenu = queryByTestId('drop-down-menu');
expect(dropDownMenu).not.toHaveClass('show');
});
// ログアウトがクリックされ、Reduxアクションがディスパッチされたときに、ドロップダウンメニューから 'show' クラスが削除されるかを確認
it('removes show class to drop down menu when clicking logout', () => {
const { queryByText, queryByTestId } = setup(loggedInState);
const displayName = queryByText('display1');
fireEvent.click(displayName);
fireEvent.click(queryByText('Logout'));
store.dispatch(authActions.loginSuccess(loggedInState));
const dropDownMenu = queryByTestId('drop-down-menu');
expect(dropDownMenu).not.toHaveClass('show');
});
// "My Profile"がクリックされたときに、ドロップダウンメニューから 'show' クラスが削除されるかを確認
it('removes show class to drop down menu when clicking My Profile', () => {
const { queryByText, queryByTestId } = setup(loggedInState);
const displayName = queryByText('display1');
fireEvent.click(displayName);
fireEvent.click(queryByText('My Profile'));
const dropDownMenu = queryByTestId('drop-down-menu');
expect(dropDownMenu).not.toHaveClass('show');
});
});
ドロップダウンメニューの機能をテストします。
displays the login and signup links when user clicks logout:
ログアウトリンクがクリックされたときに、ログインとサインアップのリンクが正しく表示されるかを検証しています。
adds show class to drop-down menu when clicking username:
ユーザーネームがクリックされたときに、ドロップダウンメニューに show クラスが追加されるかを検証しています。
removes show class to drop-down menu when clicking app log:
アプリケーションのロゴがクリックされたときに、ドロップダウンメニューから show クラスが削除されるかを検証しています。
removes show class to drop-down menu when clicking logout:
ログアウトがクリックされたときに、ドロップダウンメニューから show クラスが削除されるかを検証しています。
removes show class to drop-down menu when clicking My Profile:
「My Profile」がクリックされたときに、ドロップダウンメニューから show クラスが削除されるかを検証しています。
2.4 プロフィール画像の非同期更新(Redux)
frontend/src/pages/UserPage.spec.js
let store;
// setup 関数は、Redux ストアとテスト対象のコンポーネントを設定します。
// configureStore(false) は、ログインしていない状態をシミュレートする Redux ストアを作成します。
// render メソッドを使用して、Provider でラップされた UserPage コンポーネントを描画します。
const setup = (props) => {
store = configureStore(false);
return render(
<Provider store={store}>
<UserPage {...props} />
</Provider>
);
};
...
// updateUser API コールが成功した場合に Redux ステートが更新されることを確認するテスト
it('updates redux state after updateUser api call success', async () => {
// setupForEdit メソッドを使用してコンポーネントをセットアップします。
const { queryByText, container } = await setupForEdit();
// コンポーネント内の displayInput 要素を取得し、値を変更します。
let displayInput = container.querySelector('input');
fireEvent.change(displayInput, { target: { value: 'display1-update' } });
// updateUser API コールが成功するようにモック関数を設定します。
apiCalls.updateUser = jest.fn().mockResolvedValue(mockSuccessUpdateUser);
// Save ボタンをクリックします。
fireEvent.click(queryByText('Save'));
// DOM の変更を待機します。
await waitForDomChange();
// 更新後の Redux ステートを取得し、期待される値と一致するかを検証します。
const storedUserData = store.getState();
expect(storedUserData.displayName).toBe(mockSuccessUpdateUser.data.displayName);
expect(storedUserData.image).toBe(mockSuccessUpdateUser.data.image);
});
// updateUser API コールが成功した場合に localStorage が更新されることを確認するテスト
it('updates localStorage after updateUser api call success', async () => {
// setupForEdit メソッドを使用してコンポーネントをセットアップします。
const { queryByText, container } = await setupForEdit();
// コンポーネント内の displayInput 要素を取得し、値を変更します。
let displayInput = container.querySelector('input');
fireEvent.change(displayInput, { target: { value: 'display1-update' } });
// updateUser API コールが成功するようにモック関数を設定します。
apiCalls.updateUser = jest.fn().mockResolvedValue(mockSuccessUpdateUser);
// Save ボタンをクリックします。
fireEvent.click(queryByText('Save'));
// DOM の変更を待機します。
await waitForDomChange();
// localStorage から保存されたユーザーデータを取得し、期待される値と一致するかを検証します。
const storedUserData = JSON.parse(localStorage.getItem('hoax-auth'));
expect(storedUserData.displayName).toBe(mockSuccessUpdateUser.data.displayName);
expect(storedUserData.image).toBe(mockSuccessUpdateUser.data.image);
});
setup関数を使用してReduxストアとコンポーネントをセットアップし、fireEventを使用してユーザーのアクションを模倣しています。mockResolvedValueを使用してAPI呼び出しをモックし、テストは期待通りにReduxステートとlocalStorageが更新されるかどうかを確認しています。
frontend/src/pages/UserPage.js
onClickSave = () => {
// ログイン中のユーザーのIDを取得します。
const userId = this.props.loggedInUser.id;
// 更新するユーザー情報を準備します。
const userUpdate = {
displayName: this.state.user.displayName,
image: this.state.image && this.state.image.split(',')[1]
};
// 更新中の呼び出しを示すために pendingUpdateCall ステートを設定します。
this.setState({ pendingUpdateCall: true });
// API呼び出しを実行してユーザー情報を更新します。
apiCalls.updateUser(userId, userUpdate)
.then((response) => {
// 更新成功時の処理
const user = { ...this.state.user };
user.image = response.data.image;
// ステートを更新して編集モードを終了し、更新中のステータスをクリアします。
this.setState(
{
inEditMode: false,
originalDisplayName: undefined,
pendingUpdateCall: false,
user,
image: undefined
},
() => {
// 更新成功のアクションをディスパッチします。
const action = {
type: 'update-success',
payload: user
};
this.props.dispatch(action);
}
);
})
};
このコードは、ユーザーのプロフィール情報を非同期に更新しています。
ログイン中のユーザーのIDを取得し、更新するユーザー情報を用意します。pendingUpdateCallステートを設定して、更新中の呼び出しを示します。
apiCalls.updateUserを使用してユーザー情報を更新します。更新が成功した場合、取得したレスポンスデータを使用してユーザー情報を更新し、編集モードを終了して更新中のステータスをクリアします。最後に、Reduxアクションをディスパッチして、更新が成功したことを通知します。
3. 最後に
今までメニューバーのログインユーザー情報を表示する機能を実装しました。今回重要だったのは2つ、プロフィール写真を更新した時、非同期で更新できるようにReduxのdispatchを活用したこと、ドロップダウンメニューを非表示にするためのライフサイクルメソッド(componentWillUnmountなど)の活用法でした。次の時間からはユーザーをベースにして本格的に記事を登録する機能を実装してみます。
エンジニアファーストの会社 株式会社CRE-CO
ソンさん
【参考】
[Udemy] Spring Boot and React with Test Driven Development