What to do?
상품 목록 상세페이지 및 수정페이지 뼈대 만들기
Carousel 구현하기
상품 조회하기
상품 설명수정하기
데모 영상
Index 페이지 - Carousel
조회 페이지 - 상품 설명 조회
수정 페이지 - 상품 설명 수정
Back-End Project
이전 포스팅에서 Notion API를 사용했었다.
하지만 막상 사용해보니 너무 불편했다;;
그래서 Spring Boot로 간단한 Back-End Project를 만들어보았다.
- application.yaml
- Local PC에 My SQL을 설치하고, Commerce라는 데이터 베이스를 미리 생성
- 해당 데이터 베이스 연결정보를 적음
spring:
datasource:
url: jdbc:mysql://localhost:3306/commerce
username: root
password: 1221
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
open-in-view: true
defer-datasource-initialization: true
hibernate.ddl-auto: create
show-sql: true
properties:
hibernate.format_sql: true
hibernate.default_batch_fetch_size: 100
sql.init.mode: always
- Auditing Fields
- 메타 데이터는 JPA Auditing을 사용해 자동으로 입력되도록 함
- 생성한 시간, 생성한 사람, 수정한 시간, 수정한 사람, 삭제된 시간
@Getter
@ToString
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public abstract class AuditingFields {
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) @CreatedDate
@Column(updatable = false, name = "created_at")
private LocalDateTime createdAt;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) @LastModifiedDate
@Column(name = "modified_at")
private LocalDateTime modifiedAt;
@CreatedBy
@Column(updatable = false, length = 100, name = "created_by")
private String createdBy;
@LastModifiedBy
@Column(length = 100, name = "modified_by")
private String modifiedBy;
@Column(name = "removed_at") @Setter
private LocalDateTime removedAt;
}
- Product Entity
- Fields
- id, name, imgUrl, price, description
- Soft Delete
- 삭제되면 실제 DB에서 데이터가 삭제되지 않고, removed_at 컬럼에 삭제요청한 시간이 입력
- Select 쿼리 사용시에는 removed_at 컬럼이 NULL인 데이터만 가져옴
- Fields
@Entity
@Getter
@Table(name = "product")
@SQLDelete(sql = "UPDATE product SET removed_at = NOW() WHERE id=?")
@Where(clause = "removed_at is NULL")
public class ProductEntity extends AuditingFields{
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false) @Setter
private String name;
@Column(name="img_url")
private String imgUrl;
@Column(name="category_id")
private Long categoryId;
@Column(name="description", columnDefinition = "TEXT") @Setter
private String description;
@Column(name="price") @Setter
private Long price;
}
- JpaConfig
- 아직 인증기능을 구현하지 않아서 created_by, modified_by와 같은 컬럼은 JPA Auditing을 사용할 수 없음
- 일단 test라는 값으로 하드코딩하도록 설정
@EnableJpaAuditing
@Configuration
public class JpaConfig {
@Bean
public AuditorAware<String> auditorAware() {
return () -> Optional.of("test");
}
}
- Product DTO
- DTO는 Record를 사용해 정의
public record ProductDto(
Long id,
String name,
String imgUrl,
Long categoryId,
String description,
Long price,
LocalDateTime createdAt,
String createdBy,
LocalDateTime modifiedAt,
String modifiedBy
) {
public static ProductDto of(
String name,
String imgUrl,
Long categoryId,
String description,
Long price
){
return new ProductDto(
null,
name,
imgUrl,
categoryId,
description,
price,
null,
null,
null,
null
);
}
public static ProductDto from(ProductEntity entity){
return new ProductDto(
entity.getId(),
entity.getName(),
entity.getImgUrl(),
entity.getCategoryId(),
entity.getDescription(),
entity.getPrice(),
entity.getCreatedAt(),
entity.getCreatedBy(),
entity.getModifiedAt(),
entity.getModifiedBy()
);
}
}
- Repository
@Repository
public interface ProductRepository extends JpaRepository<ProductEntity, Long> {
List<ProductEntity> findAll();
}
- Service
- getProducts : 상품 List 가져오기
- getProduct : 상품 단건 조회하기
- updateProduct : 상품 설명 수정하기
@Service
@RequiredArgsConstructor
@Transactional
public class ProductService {
private final ProductRepository productRepository;
@Transactional(readOnly = true)
public List<ProductDto> getProducts() {
return productRepository.findAll().stream().map(ProductDto::from).collect(Collectors.toList());
}
@Transactional(readOnly = true)
public ProductDto getProduct(Long id){
return ProductDto.from(productRepository.findById(id).orElseThrow(()->{
throw new EntityNotFoundException("Invalid product id is given");
}));
}
public ProductDto updateProduct(Long id, String description){
ProductEntity entity = productRepository.findById(id).orElseThrow(()->{
throw new EntityNotFoundException("Invalid product id is given");
});
entity.setDescription(description);
return ProductDto.from(productRepository.save(entity));
}
}
- Requset
- 상품설명 Request를 수정요청은 id와 description이 옴
@Data
public class UpdateProductRequest {
private Long id;
private String description;
}
- Controller
- getProducts : 상품 List 가져오기
- getProduct : 상품 단건 조회하기
- updateProduct : 상품 설명 수정하기
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/product")
public class ProductController {
private final ProductService productService;
@GetMapping
public List<ProductDto> getProducts(){
return productService.getProducts();
}
@GetMapping("/{productId}")
public ProductDto getProduct(@PathVariable Long productId){
return productService.getProduct(productId);
}
@PutMapping
public ProductDto getProduct(@RequestBody UpdateProductRequest req){
return productService.updateProduct(req.getId(), req.getDescription());
}
}
- data.sql
- 앱 실행 시 다음의 쿼리를 실행해서 자동으로 예제 데이터가 들어가도록 함
insert into product
(id, name, img_url, category_id, description, price, created_at, created_by, modified_at, modified_by)
values
(1, '전기 자전거', NULL, 1, NULL, 1000000, now(), 'test', now(), 'test');
insert into product
(id, name, img_url, category_id, description, price, created_at, created_by, modified_at, modified_by)
values
(2, '맥북', NULL, 2, NULL, 2000000, now(), 'test', now(), 'test')
- 앱 실행하기
Carousel
- 라이브러리 설치
- nuka-carousel 라이브러리를 사용
- 구현할 모습
- Carousel은 10초에 한장씩 자동으로 넘어가도록 설정
- 썸네일을 클릭하면 해당 사진으로 이동
- 구현할 방법
- Carousel이미지, Thumbnail 이미지 링크를 images라는 배열에 저장
- 현재 보여주고 있는 이미지를 나타낼 수 있는 slideIndex라는 state를 만듬
- nuka 라이브러리를 사용해 Carousel 만들기
- Thumbnail 만들고, onClick 이벤트 구현하기
- Products.tsx
import Image from 'next/image'
import Carousel from 'nuka-carousel/lib/carousel'
import { useState } from 'react'
const images = [
{
original: 'https://picsum.photos/id/1000/1000/600/',
thumbnail: 'https://picsum.photos/id/1000/150/150/',
},
{
original: 'https://picsum.photos/id/1001/1000/600/',
thumbnail: 'https://picsum.photos/id/1001/150/150/',
},
{
original: 'https://picsum.photos/id/1002/1000/600/',
thumbnail: 'https://picsum.photos/id/1002/150/150/',
},
]
export default function MyCarousel() {
const [slideIndex, setSlideIndex] = useState<number>(0)
const handleSlideIndex = (idx: number) => () => setSlideIndex(idx)
return (
<>
{/* Carousel */}
<Carousel
animation="zoom"
autoplay
withoutControls
speed={10}
slideIndex={slideIndex}
>
{images.map((item) => (
<Image
key={item.original}
src={item.original}
width={1000}
height={600}
alt="big-image"
layout="reponsive"
/>
))}
</Carousel>
{/* Thumbnails */}
<div style={{ display: 'flex' }}>
{images.map((item, idx) => (
<div key={idx}>
<Image
src={item.thumbnail}
alt="small-image"
width={100}
height={60}
onClick={handleSlideIndex(idx)}
/>
</div>
))}
<div/>
</div>
</>
)
}
Editing Text Box
- 라이브러리
- 상품 설명을 단순한 텍스트가 아니라 서식이 있는 텍스트를 보여주고 싶다.
- 또한 수정 폼에서도 서식 있는 텍스트로 수정하고 싶다.
- 이 경우 사용할 수 있는게 페이스북에서 만든 draft.js라는 라이브러리다.
- 고민할 점
- 상품 정보를 결국 데이터베이스에 저장해야 되는데, 서식 있는 텍스트는 어떻게 저장할 수 있을까?
- 테이블에 서식 있는 텍스트를 표현할 수 있는 Json 객체를 stringify 해서 저장하면 된다
예를 들어 아래와 같이 id가 1번인 상품의 설명을 다음과 같이 수정하고 저장 버튼을 눌르면
데이터베이스를 조회할 때 Stringify된 Json 객체가 들어간걸 볼 수 있다
{"blocks":[{"key":"cl5a","text":"tesatsfdsafdsafdsafadsf","type":"unstyled","depth":0,"inlineStyleRanges":[{"offset":7,"length":16,"style":"BOLD"}],"entityRanges":[],"data":{}},{"key":"13rn5","text":"dsafdsafdsf","type":"unstyled","depth":0,"inlineStyleRanges":[{"offset":0,"length":11,"style":"BOLD"}],"entityRanges":[],"data":{}}],"entityMap":{}}
- Editor Component
import { Dispatch, SetStateAction } from "react"
import { EditorState } from "draft-js"
import dynamic from "next/dynamic"
import { EditorProps } from "react-draft-wysiwyg"
import 'react-draft-wysiwyg/dist/react-draft-wysiwyg.css'
const Editor = dynamic<EditorProps>(
()=>import('react-draft-wysiwyg').then((module)=>module.Editor),
{
ssr:false
}
)
export default function MyEditor({
editorState,
readOnly=false,
onSave,
onEditorStateChange
}
:{
editorState:EditorState,
readOnly?:boolean,
onSave?:()=>void,
onEditorStateChange?:Dispatch<SetStateAction<EditorState|undefined>>
}){
console.log(readOnly)
return (
<div>
<Editor
readOnly={readOnly}
toolbarHidden={readOnly}
editorState={editorState}
toolbarClassName="editor-toolbar-hidden"
wrapperClassName="wrapper-class"
editorClassName="editor-class"
toolbar={{
options:['inline', 'list', 'textAlign', 'link']
}}
localization={{
local:'ko'
}}
onEditorStateChange={onEditorStateChange}
/>
{readOnly?? <button onClick={onSave}>Save</button>}
</div>
)
}
- Text Editing Box
- useRouter를 사용해 edit, productId를 받아 readOnly인지 판단
- readOnly인 경우 수정화면이 나오지 않음
import CustomEditor from '../../../components/Editor'
import { EditorState, convertFromRaw, convertToRaw } from 'draft-js'
import { useRouter } from 'next/router'
import { useState, useEffect } from 'react'
export default function EditProduct() {
const router = useRouter()
const {id:productId, edit} = router.query
const [editorState, setEditorState] = useState<EditorState|undefined>(undefined)
const [readOnly, setReadOnly] = useState<boolean>(false);
// edit모드 & productId가 존재 → 수정가능
useEffect(()=>{
setReadOnly(!(edit&&productId))
}, [edit])
useEffect(()=>{
if (productId == null){
return
}
// 상품 정보 가져오기
const endPoint = `/api/get-product?id=${productId}`
fetch(endPoint)
.then(res=>res.json())
.then(data => data.data.description)
.then((description) => {
description
// description 필드의 값이 기존에 있는 경우 → update state
?setEditorState(EditorState.createWithContent(convertFromRaw(JSON.parse(description))))
// description 필드의 값이 기존에 없는 경우 → Empty State
:setEditorState(EditorState.createEmpty())
})
.catch(err=>console.error(err))
}, [productId])
// 수정내용 저장
const handleSave = () => {
if (!editorState){
return
}
fetch('/api/update-product', {
method:'POST',
body: JSON.stringify({
id : productId,
description : JSON.stringify(convertToRaw(editorState.getCurrentContent()))
})
}).then(res=>res.json())
.then((data)=>{
alert(data.message)
return;
}).catch(err=>console.error(err))
}
return (
<>
{editorState!=null &&
(
<CustomEditor
editorState={editorState}
onSave = {handleSave}
onEditorStateChange = {setEditorState}
readOnly={readOnly}
/>
)
}
{/* edit모드인 경우에만 저장버튼 보이기 */}
{
!readOnly&&<button onClick={handleSave}>Save</button>
}
</>
)
}
- Home 화면
export default function Home() {
return (
<>
<Head>
<title>Commerce</title>
<meta name="description" content="Commerce" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icons" href="/favicon.ico" />
</Head>
<MyCarousel/>
<EditProduct/>
</>
)
}
API
- 상품설명 조회하기
import type { NextApiRequest, NextApiResponse } from 'next'
import { get } from 'http'
import axios from 'axios'
// API response
type Data = {
message : String,
data : {
id:Number,
name:String,
imgUrl:String,
categoryId:String,
descripion : String,
price : Number
} | undefined
}
async function getProduct(id:number) {
try {
const endPoint = `http://localhost:8080/api/product/${id}`
const res = await axios.get(endPoint)
console.debug(res.data??res);
return res;
} catch (e) {
console.error(JSON.stringify(e))
}
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
const { id } = req.query
if (id == null){
return res.status(400).json({ message: 'product id is not given', data : undefined })
}
try {
const items = await getProduct(Number(id))
res.status(200).json({ message: 'Get item success', data: items?.data })
} catch (e) {
console.error(e)
return res.status(400).json({ message: 'Fail to get item' , data : undefined})
}
}
- 상품설명 수정하기
import type { NextApiRequest, NextApiResponse } from 'next'
import axios from 'axios'
async function updateProduct(id:Number, description:String) {
try {
const endPoint = "http://localhost:8080/api/product"
const data = {id, description}
const res = await axios.put(endPoint, data)
console.debug(res);
return res.data;
} catch (e) {
console.error(JSON.stringify(e))
}
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const { id, description } = JSON.parse(req.body);
await updateProduct(id ,description)
.then(()=>{
res.status(200).json({ message: 'Update item success'})
})
.catch((err)=>{
res.status(500).json({message:'Fail to update item'})
console.error(err);
})
} catch (e) {
console.error(e)
return res.status(400).json({ message: 'Fail to update item' })
}
}
'JS' 카테고리의 다른 글
[Next JS] Commerce Project #6 (0) | 2023.04.15 |
---|---|
[Next JS] Commerce Project #5 (0) | 2023.04.13 |
[Next JS] Commerce Project #4 (0) | 2023.04.13 |
[Next JS] Commerce Project #3 (0) | 2023.04.09 |
[Next JS] Commerce Project #1 (0) | 2023.04.05 |