概要
フロントエンド:TypeScript React (一応Next.js)
バックエンド: Java SpringBoot
でB2CのECショップサイトを作ってます。
今回はユーザーが商品の一覧を見る機能を実装します。
内容としましてはフロントエンドの実装方法を焦点に充てています。
バックエンドの実装方法は書きません。
ユースケース
商品数を分割して表示できるよう ページネーションの動きを実装します。以下gifアニメはその時の挙動です。
開発環境
OS:windows10
バックエンド側:
IDE:IntelliJ Community
spring-boot-starter-parent 2.75
java : 11
データベース
mysql:8.0.29
クライアントソフト: MySQL Workbench
フロントエンド:
IDE:VScode
├── @types/node@18.11.15
├── @types/react-dom@18.0.9
├── @types/react@18.0.26
├── axios@1.2.1
├── eslint-config-next@13.0.6
├── eslint@8.29.0
├── next-auth@4.18.7
├── next@13.0.6
├── react-bootstrap@2.7.0
├── react-dom@18.2.0
├── react@18.2.0
├── styled-components@5.3.6
├── styled-jsx@5.1.1
└── typescript@4.9.4
実装
バックエンド
github 参照
ドメイン
https://github.com/RYA234/springboot-ecshop-rest-api/tree/master/src/main/kotlin/com/example/restapi/domain/product
実装
フロント側
1.レイアウト問題-色付きの四角形を等間隔で並べる方法を確認
css flexBoxの使い方の確認ですね。
ソースコード
// 検証用引数の数字が中央に表示される
function Block( props : {count : number} ) {
return(
<div >
<div className ="Block">
<div className="Font">{props.count}</div>
</div>
<style jsx>{`
// コンテンツのサイズ 色
.Block{
width:200px;
height:200px;
background-color: yellow;
border:1px solid black;
}
// フォントのサイズと位置
.Font{
font-size: 50px;
text-align: center;
padding:60px 0;
}
`}</style>
</div>
)
}
// flexboxで要素を整列できるか検証
// 並べる要素はブロック10個
export default function ProductBriefManySet(){
// ブロック配列を作成
const totalNumber : number = 10;
const blocks : JSX.Element[] = [];
for(let i = 0; i < totalNumber; i++){
blocks.push(<Block count={i+1}/>)
}
return(
<>
<div className="Layout">
{blocks}
</div>
<style jsx>{`
// 大枠左から右に並べる
// 横幅が一杯になったら下に移動する。
.Layout{
display:flex;
flex-wrap:wrap;
}
`}</style>
</>
)
}
結果
横幅を変えた時の挙動
2.SpringBootのRestApiからデータを取得して 商品一覧にデータを詰める
前に作った商品コンポーネントと1を使って商品コンポーネントを複数並べます。。
注意点:画像の表示は後回しにしています。
ページ数は固定している体で実装します。
ソースコード
import { SetStateAction, useEffect, useState } from "react";
import ProductContent from "../../component/productContent"
import { ProductRequest } from "../../types/product/productRequest";
import * as productService from '../../service/productService';
import { Product } from "../../types/product/product";
// 商品一覧機能検証ページその2(ページング機能なし)
// 商品コンポーネント使う
// restapiを使う
// ページネーション機能は使わない
export default function ProductListIncludingApi(){
const [pagingProduct, setPagingProduct] = useState<ProductRequest | undefined>()
const productsContents : JSX.Element[] = [];
const pageNumber : number = 1;
const pageSize : number = 10;
const categoryId : number = 1;
// 初回実行
useEffect(() => {
getProductByCategory()
}, [])
const getProductByCategory = async () => {
productService
.getProductsByCategoryId(pageNumber - 1,pageSize,categoryId)
.then((response: { data: SetStateAction<ProductRequest | undefined>; })=>{
setPagingProduct(response.data)
console.log(pagingProduct)
})
}
pagingProduct?.content.map((product : Product) => {
productsContents.push(<ProductContent productName={product.name} priceWithoutTax={product.price} priceIncludingTax={((product.price*(1+product.taxRate)).toFixed()) as string} imageURL='/sampleProduct1.JPG'/>)
})
return(
<>
<div>商品一覧</div>
<div>カテゴリー:{pagingProduct?.categoryName}</div>
<div>層ページ数{pagingProduct?.totalPages}</div>
<div>現在のページ番号:{(pagingProduct?.pageNo as number) + 1}</div>
<div className='Layout'>
{
productsContents.map((content, index) => {
return(
<div className="Child" key={index}>
{content}
</div>
)
})
}
</div>
<style jsx>{`
// 大枠
.Layout{
display:flex;
flex-wrap:wrap;
background-color:gray;
align-items:flex-end;
}
// 子の幅を指定 これしないとレイアウトが崩れる
.Child{
flex-basis:200px;
}
`}</style>
</>
)
}
import axios from 'axios';
export const getProductById = ( productId : number) =>{
return axios.get(`http://127.0.0.1:5000/api/products/?id=`+ productId,
{ headers: {
'Access-Control-Allow-Origin': '*',
} }
);
};
export const getProductsByCategoryId = ( pageNo : number,pageSize:number, categoryId:number) =>{
return axios.get(`http://127.0.0.1:5000/api/products?`
+`pageNo=`+ pageNo
+`&pageSize=`+ pageSize
+`&category=`+ categoryId,
);
};
import Image from 'next/image';
interface Props{
productName:string;
priceWithoutTax:number;
priceIncludingTax:string;
imageURL:string;
}
/**
*
* @param props 商品情報
* @returns 商品情報を表示するコンポーネント
*/
export default function ProductContent(props : Props){
return(
<>
<div className = 'ContentPadding ContentLayout'>
<Image src={props.imageURL}
width={160}
height={150}
alt='logo' />
<div>{props.productName}</div>
<div className='PriceWithoutTaxAndQuantityLayout'>
<div>{`税抜` + props.priceWithoutTax +`円`}</div>
<div>数量</div>
<div><select>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
</div>
</div>
<div className='PriceIncludingTaxAndBuyButtonLayout'>
<div>{`税込` + props.priceIncludingTax +`円`}</div>
<button >購入</button>
</div>
</div>
<style jsx>{`
// 大枠の空白部分を担当
.ContentPadding{
padding: 20px;
border:1px solid black;
}
// 大枠のレイアウト(縦並び)
.ContentLayout{
position:relative;
flex:1;
width:200px;
height:350px;
}
// 税抜き価格と数量のレイアウト(横並び-等間隔)
.PriceWithoutTaxAndQuantityLayout{
display:flex;
justify-content:space-between;
}
// 税込み価格と購入ボタンのレイアウト(横並び-等間隔)
.PriceIncludingTaxAndBuyButtonLayout{
display:flex;
justify-content:space-between;
}
`}</style>
</>
)
}
結果
カテゴリーID:1
ページサイズ:10
ページ番号1
の表示結果です。
3.ページネーション機能を実装
下の記事で作ったコンポーネントを使って、ページネーションを実装します。
ソースコード
import { SetStateAction, useEffect, useState } from "react";
import ProductContent from "../../component/productContent"
import { ProductRequest } from "../../types/product/productRequest";
import * as productService from '../../service/productService';
import { Product } from "../../types/product/product";
import Pagination from "../../component/pagination";
// 商品一覧機能検証ページその3(ページング機能あり)
// 商品コンポーネント使う
// restapiを使う
// ページネーション機能を新規追加する
export default function ProductListIncludingPagination(){
const [pagingProduct, setPagingProduct] = useState<ProductRequest | undefined>()
const [pageNo, setPageNo] = useState<number>(1);
const productsContents : JSX.Element[] = [];
const pageSize : number = 10;
const categoryId : number = 1;
// pageNoが変更後にデータベースから対応するページの情報を再取得する
useEffect(() => {
getProductByCategory()
},[pageNo])
// api
const getProductByCategory = async () => {
productService
.getProductsByCategoryId(pageNo - 1,pageSize,categoryId)
.then((response: { data: SetStateAction<ProductRequest | undefined>; })=>{
setPagingProduct(response.data)
console.log(pagingProduct)
})
}
pagingProduct?.content.map((product : Product) => {
productsContents.push(<ProductContent productName={product.name} priceWithoutTax={product.price} priceIncludingTax={((product.price*(1+product.taxRate)).toFixed()) as string} imageURL='/sampleProduct1.JPG'/>)
})
return(
<>
<div>商品一覧</div>
<div>カテゴリー:{pagingProduct?.categoryName}</div>
<div>総ページ数{pagingProduct?.totalPages}</div>
<div>現在のページ番号:{(pagingProduct?.pageNo as number) + 1}</div>
<div className='Layout'>
{
productsContents.map((content, index) => {
return(
<div className="Child" key={index}>
{content}
</div>
)
})
}
</div>
<Pagination totalPage={pagingProduct?.totalPages as number} pageNo={pageNo} setPageNo={setPageNo}/>
<style jsx>{`
// 大枠
.Layout{
display:flex;
flex-wrap:wrap;
background-color:white;
}
// 子の幅を指定 これしないとレイアウトが崩れる
.Child{
flex-basis:200px;
//align-self:stretch;
}
`}</style>
</>
)
}
import { setHttpClientAndAgentOptions } from "next/dist/server/config";
import { useState, useEffect, useCallback, EventHandler, SetStateAction, Dispatch } from "react";
import { MouseEventHandler } from "react";
/**
* @remarks ページネーション コンポーネント
* 親コンポーネントに現在のページ数を渡します。
* RestApiを呼ぶ関数は親コンポーネント側で行います。
*
* @param totalPage: 総ページ数:固定値として使われる想定
* @param pageNo:現在のページ番号を取得する useState[pageNo, setPageNo]の1つ目の引数
* @param setPageNo:現在のページ番号を更新する useState[page,setPage]の2つ目の引数
*/
// 参考URL
//
export default function Pagination(props:{totalPage:number ,pageNo:number, setPageNo:Dispatch<SetStateAction<number>>}) {
// const [currentPageNo,setCurrentPageNo] = useState<number>(1);
let currentPageNo = 1;
const totalPage: number = 10;
let paginationContent: JSX.Element[] = [];
// 前へボタンを押したときの処理
const backHandler = () => {
props.setPageNo( props.pageNo - 1)
};
// 次へボタンを押したときの処理
const nextHandler = () => {
props.setPageNo(props.pageNo + 1)
console.log(props.pageNo);
};
// ページ番号を押したときの処理
const selectNumberHandler = (pg: number) => {
props.setPageNo(pg)
};
return (
<>
<div className="Layout">
{/* 前のページに戻る-ページ数が最初の場合は表示されない */}
<div className="Child">
{props.pageNo != 1 ?
<button className="Font" onClick={backHandler}>前へ</button>
:<div className="Font">    </div> // 「前へ」ボタンが非表示でもレイアウトを維持するための処置。(何もしないと崩れる)
}
</div>
{/* 即時関数を使って1からtotalNumberの数字を作成する。クリックすると押した番号のデータを取得するrestapiを呼ぶ関数を実行する */}
{(() => {
for (let i = 0; i < props.totalPage; i++) {
paginationContent.push(
<>
<button
className= { props.pageNo== i+1 ? 'SelectedChild' :'Child'}
onClick={()=>selectNumberHandler(i+1)}
>
{i + 1}
</button>
</>
);
}
return <div>{paginationContent}</div>;
})()}
{/* 次のページへ進む-ページ数が最後の場合は表示されない */}
<div className="Child">
{props.pageNo != props.totalPage ?
<button className="Font" onClick={nextHandler}>次へ</button>
:<div className="Font">    </div> // レイアウトを維持するために空欄を入れる
}
</div>
 
</div>
<style jsx>{`
// 大枠 要素を横並びにする
.Layout {
display: flex;
flex-wrap: wrap;
align-items: center;
}
// 子の幅 間隔を決定する
.Child {
flex-basis: auto;
align-items: center;
background-color: white;
margin: 3px;
border: none;
}
// 現在のページを明示するために背景色を変える 透明な青色とする
.SelectedChild{
flex-basis: auto;
align-items: center;
background-color: #1c5bcf85;
margin: 5px;
border: none;
}
// 文字のフォントサイズ 背景色設定
.Font {
align-items: center;
background-color: white;
text-align: center;
}
// マウスオーバー時の背景色は透明な灰色
button:hover {
background-color: #1c5bcf85;
}
// 子要素のボタン感を無くすために 色と境界線を無くす
button {
background-color: white;
border: none;
}
`}</style>
</>
);
}
動作確認
左側ブラウザ 右側MySqlworkbench
参考
杏林堂ネットスーパー