[RB's Ground] 카테고리 기능 변경

찐새·2023년 4월 23일
0

개인 프로젝트

목록 보기
1/3
post-thumbnail

RB's Ground는 웹 개발 공부 겸 망가져도 괜찮은 장난감으로 만든 개인 블로그 사이트이며, 공부 도중 만난 문제와 해결 과정을 기록합니다.

개발 환경

카테고리 기능 변경

일전에 사용하던 카테고리는 게시물 하나당 한 개만 기록하도록 했었다. 처음 만들 당시, 여러 개를 적용하는 방법도 몰랐고, 필요성도 못 느꼈다. 그러나 Velog네이버 블로그 등의 태그를 사용하면서 여러 개 적용되는 기능을 구현해 보고 싶어졌다. 추후 카테고리를 통한 필터 기능을 위해서라도 해당 기능은 필요했다.

1. Many to many Schema

먼저 prisma의 스키마를 다 대 다 형태로 변경했다. 단순히 릴레이션을 양쪽으로 연결하고 배열로 정의하면 되는 줄 알았으나, 내가 사용한 prisma - my SQL의 경우 다 대 다 릴레이션을 지원하지 않았다.

many to many 공식 문서에서는 두 가지 방법으로 다 대 다 릴레이션을 알려줬다. 먼저 시도한 것은 명시적 다 대 다(explicit many to many)였다.

model MyBlog {
  id       Int        @id @default(autoincrement())
  category CategoriesOnBlog[]
}

model Category {
  id       Int      @id @default(autoincrement())
  title    String
  content  String     @db.LongText
  category String
  posts    CategoriesOnBlog[]
}

model CategoriesOnBlog {
  blog       MyBlog     @relation(fields: [blogId], references: [id])
  blogId     Int
  category   Category @relation(fields: [categoryId], references: [id])
  categoryId Int
  assignedAt DateTime @default(now())

  @@id([postId, categoryId])
}

그러나 DB에 저장하는 방법을 몰라 이 모델을 사용하는 일은 없었다. 교량이 되는 CategoriesOnBlogblogIDcategoryId를 알려줘야 하는데, 아직 생성하지 않은 게시물과 카테고리의 아이디를 어떻게 얻어야 할지 몰랐다.

구글링해 본 바로는, 게시글과 태그를 생성한 후 각각의 아이디를 연결하면 된다고. 그러나 작성할 코드가 떠오르지 않아 포기했다.

대신 암시적 다 대 다(Implicit many to many)로 릴레이션을 변경했다.

model MyBlog {
  id       Int        @id @default(autoincrement())
  title    String
  content  String     @db.LongText
  category Category[]
}

model Category {
  id       Int      @id @default(autoincrement())
  category String
  posts    MyBlog[]
}

릴레이션을 통한 캐스케이드는 불가능했지만(sql은 지원하지 않는다고 했다), 로직을 생각하기에는 훨씬 편했다. 게시물은 그냥 생성하고, 카테고리에 없으면 생성 후 연결, 있으면 찾아서 연결하면 되니까.

2. Categories UI

내가 생각한 Categories의 기능은 다음과 같다.

  • (1) 입력 칸을 누르면 기존의 카테고리 목록이 나온다. 클릭하면 추가된다.
  • (2) 뱃지 형태이며, 입력 박스 안에 자리한 것처럼 보이고, 카테고리가 추가될 때마다 입력칸은 옆으로 밀린다.
  • (3) 뱃지의 x를 클릭하면 제거된다.
  • (4) 입력 박스 끝의 x를 클릭하면 추가된 모든 카테고리가 지워진다.

조건 (1)

이 부분은 이전에 작성한 글인 조잡한 검색 리스트 만들기자동 완성 검색 리스트 구현을 참고해서 구현했다.

const categories = ["TypeScript","JavaScript","HTML5","Next.js","React","Git","Node.js"];

const Categories = () => {
  
  const [newCategory, setNewCategory] = useState("");
  const [newCategories, setNewCategories] = useState<string[]>([]);
  const [isInputFocused, setIsInputFocused] = useState(false);
  const [isLiOver, setIsLiOver] = useState(false);
  return (...)
}

더미 데이터로 쓸 배열과 여타 변수이다. newCategory는 새로 입력하는 카테고리, newCategories는 포스트에 추가되는 카테고리들, isInputFocused는 입력 박스에 포커스가 왔는지 여부, isLiOver는 기존 카테고리 리스트에 마우스를 올렸는지 여부를 의미한다.

const onSearchCategory = ({
  currentTarget: { value },
}: ChangeEvent<HTMLInputElement>) => {
  setNewCategory(value);
};

const onAddExistedCategory = ({
  currentTarget: { textContent },
}: MouseEvent<HTMLLIElement>) => {
  setNewCategories((prev) => Array.from(new Set([...prev, textContent])));
  setIsInputFocused(false);
};

onSearchCategoryonChange에 해당하고, onAddExistedCategory는 기존 카테고리 목록을 눌렀을 때 추가되는 함수이다. 카테고리가 중복 추가되지 않도록 new Set()을 사용했다. 추가하면 기존 목록이 닫힌다.

const onAddNewCategory = (e: KeyboardEvent<HTMLInputElement>) => {
  if (e.key !== "Enter" || !e.currentTarget.value) return;
  setNewCategories((prev) => Array.from(new Set([...prev, e.currentTarget.value])));
  setNewCategory("");
};

직접 입력한 카테고리를 추가하는 함수이다. enter를 눌러 추가하기 위해 keyDown 이벤트로 매칭했다. 역시 중복을 방지하기 위해 Set을 사용했다.

작성하고 보니 문제가 있었다. 예를 들면, TypeScripttypescript는 같은 카테고리지만 대소문자가 달라 추가로 입력된다. 이러한 점을 방지하기 위해 체크 코드를 추가했다.

const currentCategory = e.currentTarget.value
  .toLowerCase()
  .replace(/[\W ]/, "");
const exsitedCategory = categories.map((item) => item.category.toLowerCase().replace(/[\W ]/, ""));
const checkedCategory = exsitedCategory.includes(currentCategory)
  ? categories.find((item) =>
    item.category.toLowerCase().replace(/[\W ]/, "") === currentCategory
  ).category
  : newCategory;

현재값을 소문자로 변경하고, 공백과 특수문자를 제거했다. 더미 배열도 같은 조건으로 만들었다. 마지막으로 현재값이 더미 배열에 존재하면 더미 배열에서 같은 값을 찾아 할당한다. 없다면 새 값을 할당한다. 이렇게 나온 checkedCategorye.currentTarget.value 대신 setNewCategories에 추가했다.

  {isInputFocused ? (
          <ul
            className="absolute z-10 max-h-60 min-h-fit w-full divide-y-2 overflow-y-scroll rounded-md border border-gray-300"
            id="category"
          >
            {categories.map(({ id, category }) => (
              <li
                key={id}
                className="w-full cursor-pointer bg-white px-3 py-2 placeholder-gray-400 shadow-sm hover:bg-gray-300"
                onClick={onAddExistedTag}
                onMouseOver={() => setIsLiOver(true)}
                onMouseLeave={() => setIsLiOver(false)}
                hidden={
                  !category
                    .toLowerCase()
                    .replace(/[\W ]/, "")
                    .includes(newCategory.toLowerCase().replace(/[\W ]/, ""))
                }
              >
                {category}
              </li>
            ))}
          </ul>
        ) : null}

입력 박스에 포커스가 되면 목록이 나타난다. lihidden은 입력한 알파벳/낱말이 포함된 카테고리 외에는 숨긴다. 나머지는 위에서 설정한 함수 등을 적소에 배치한 것이다.

조건 (2)

UI 부분은 react-selectCreateReactSelect를 참고했다.

interface BadgeProps {
  label: string;
  onClick?: () => void;
  hasCancel?: boolean;
}

export const Badge = ({ label, onClick, hasCancel }: BadgeProps) => {
  return (
    <div className="mx-0.5 flex flex-wrap items-center justify-center rounded-md bg-amber-400 py-0.5 px-1 hover:bg-amber-500">
      <div className="px-1 text-sm">{label}</div>
      {hasCancel && (
        <div className="cursor-pointer px-0.5" onClick={onClick}>
          <svg
            height="14"
            width="14"
            viewBox="0 0 20 20"
            aria-hidden="true"
            focusable="false"
            className="h-4 w-4"
          >
            <path d="M14.348 14.849c-0.469 0.469-1.229 0.469-1.697 0l-2.651-3.030-2.651 3.029c-0.469 0.469-1.229 0.469-1.697 0-0.469-0.469-0.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-0.469-0.469-0.469-1.228 0-1.697s1.228-0.469 1.697 0l2.652 3.031 2.651-3.031c0.469-0.469 1.228-0.469 1.697 0s0.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c0.469 0.469 0.469 1.229 0 1.698z"></path>
          </svg>
        </div>
      )}
    </div>
  );
};

특별할 것 없는 뱃지 UI다.

<div className="relative">
  <div
    className={cls(
      isInputFocused ? "border-amber-400 ring-2 ring-amber-400" : "",
      "relative box-border flex flex-1 flex-wrap items-center overflow-hidden rounded-md border-2 border-gray-300 py-0.5 px-2 shadow-sm"
    )}
    >
    {newCategories.map((tag, idx) => (
      <Badge
        key={tag}
        label={tag}
        hasCancel
        />
    ))}
    <div className="visible m-0.5 box-border inline-grid flex-1 grid-cols-2 py-0.5 text-gray-500 outline-none">
      <input
        id="categories"
        type="text"
        autoComplete="off"
        className="m-0 w-full min-w-[2px] border-0 border-transparent p-0 text-inherit opacity-100 focus:border-transparent focus:ring-0"
        />
    </div>
    <div className="cursor-pointer px-0.5">
      <svg
        height="14"
        width="14"
        viewBox="0 0 20 20"
        aria-hidden="true"
        focusable="false"
        className="h-6 w-6"
        >
        <path d="M14.348 14.849c-0.469 0.469-1.229 0.469-1.697 0l-2.651-3.030-2.651 3.029c-0.469 0.469-1.229 0.469-1.697 0-0.469-0.469-0.469-1.229 0-1.697l2.758-3.15-2.759-3.152c-0.469-0.469-0.469-1.228 0-1.697s1.228-0.469 1.697 0l2.652 3.031 2.651-3.031c0.469-0.469 1.228-0.469 1.697 0s0.469 1.229 0 1.697l-2.758 3.152 2.758 3.15c0.469 0.469 0.469 1.229 0 1.698z"></path>
      </svg>
    </div>
  </div>
</div>

코드를 작성하면서 새로운 점을 두 가지 배웠다. relative 안에 relative를 사용하면 위치를 정하지 않아도 순차적으로 배치된다. 카테고리가 하나도 없을 때는 input이 가장 앞에 있지만, 추가될 때마다 뱃지가 그 앞을 차지하고, input은 옆으로 밀린다.

다른 하나는 Tailwind CSS에서 input태그의 포커스 테두리를 제거할 때 focus:ring-0을 설정한다. outline:none을 사용해 일반적인 CSS를 사용할 수도 있었지만, style 끼어 있는 게 꼴받아서 찾아봤더니 만족스런 결과물이 나왔다.

조건 (3), (4)

const onDeleteCategory = (idx: number) => {
  setNewCategories((prev) => prev.filter((_, i) => i !== idx));
};

{newCategories.map((tag, idx) => (
  <Badge
    key={tag}
    label={tag}
    onClick={() => onDeleteCategory(idx)}
    hasCancel
    />
))}

비교적 쉬웠다. 인덱스를 onDeleteCategory에 보내서 그 인덱스를 제외한 나머지를 필터했다.

const onAllDeleteCategories = () => {
  setNewCategories([]);
};

당연히 카테고리 전부 비우는 것도 쉬웠다. 빈 배열 넣어주면 되니까.

이렇게 전반적인 UI 작업은 마무리지었다.

3. 카테고리 업로드 / 수정

이제 api로 입력한 카테고리 리스트를 보내 DB에 올리는 작업을 복기해 보자.

3-1. 업로드

가장 고민하게 만들었던 점은 이미 DB에 있는 카테고리와 어떻게 분리하여 연결할 것인가였다. 일단 create해 보았더니 같은 카테고리여도 새로 생성되어 난감했다.

기존의 것과 연결하려면 connect를 해야 했는데, 카테고리 ID를 어떻게 가져올지 감이 안 잡혔다. 그래서 다음과 같은 로직을 떠올렸다.

  • (1). 모든 카테고리 정보를 가져온다.
  • (2). 카테고리 요청값이 (1)에 포함되어 있으면 connect한다.
  • (3). 미포함이라면 create한다.
// (1)
const allCategories = await client.category.findMany({});

// (2)
const existedCategories = allCategories.filter((item) => categories.includes(item.category)).map((item) => ({ ...item }));

// (3)
const nonExistedCategories = categories.filter((existed) => !allCategories.map((item) => item.category).includes(existed));

existedCategoriesid가 필요하기 때문에 객체 배열로 할당했으며, nonExistedCategories는 카테고리 명만 있으면 되었기에 문자 배열로 할당했다.

구분한 정보를 blog와 연결한다.

const post = await client.myBlog.create({
  data: {
    title,
    content,
    category: {
      create: nonExistedCategories.map((category) => ({ category })),
      connect: existedCategories.map((category) => ({ id: category.id })),
    },
  },
});

다 대 다로 연결하기 때문에 createconnect에 배열을 값으로 입력했다.

3-2. 수정

로직은 업로드와 크게 다르지 않지만, 하나를 더 추가해야 했다. 바로 게시물에서 제거한 카테고리 목록의 연결을 끊는 것. 처음에는 단순히 deleteMany를 하면 되겠다 싶었다. 결과는 당연하게도 카테고리 자체가 지워졌다. 해서 다시 몇 가지 로직을 생각했다.

  • (1). 모든 카테고리 정보를 가져온다.
  • (2). 게시물과 연결된 카테고리 정보를 가져온다.
  • (3). 카테고리를 구분한다.
    • (3)-1. 완전히 신규인지
    • (3)-2. 게시물에는 신규이나 (1)에 존재하는지
    • (3)-3. 요청값이 (2)에 존재하지 않는지
  • (4). 각각 여부에 따라 connect, create, disconnect한다.

'connect가 있으니 disconnect도 있지 않을까'하는 마음에 공식 문서를 찾아보니 역시 있었다. 그러니 코드가 수월해졌다.

// (1) 모든 카테고리 정보
const allCategories = await client.category.findMany({});

// (2) 게시글과 연결된 카테고리 정보
const existedCategories = await client.category.findMany({
  where: {
    posts: {
      some: {
        id: +id,
      },
    },
  },
});

// (3)-3 삭제할 카테고리 disconnect
const deleteCategories = existedCategories.filter(
  (item) => !categories.includes(item.category)
);

// 요청값에는 있으나 (2)에 없는 카테고리 (신규 구분)
const newCategories = categories.filter(
  (category) =>
  !existedCategories.map((item) => item.category).includes(category)
);

// (3)-2 신규이나 이미 (1)에 있는 카테고리 connect
const alreadyCategories = allCategories
.filter((item) => newCategories.includes(item.category))
.map((item) => ({ ...item }));

// (3)-1 완전히 새로운 카테고리 create
const nonExistedCategories = newCategories.filter(
  (existed) => !allCategories.map((item) => item.category).includes(existed)
);

구분한 정보로 (4)를 작성했다.

const updatePost = await client.myBlog.update({
  where: {
    id: +id,
  },
  data: {
    title,
    content,
    category: {
      disconnect: deleteCategories.map((category) => ({ id: category.id })),
      create: nonExistedCategories.map((category) => ({
        category,
      })),
      connect: alreadyCategories.map((category) => ({ id: category.id })),
    },
  },
});

이것으로 내가 원했던 카테고리 기능을 완성했다.

4. 결과 화면

카테고리 업로드

카테고리 수정

아직 부족한 게 많이 있지만, 차츰차츰 수정하고 있다. 카테고리 변경 시 게시글에 즉각 반영되지 않는 이유는 SSG이기 때문... 시간 지나서 다시 빌드되면 생길 것이다. 아니면 나중에 revalidate 설정해야지.

프로덕션에서 먹히지 않는 스타일도 해결해야겠다. 분명 리스트에 마우스를 대면 색 바뀌게 해놨는데. 개발할 때는 되던 게 배포에서 안 되면 참 난감하다.

아무튼 이것으로 카테고리(태그) 기능 구현을 마무리한다.

profile
프론트엔드 개발자가 되고 싶다

0개의 댓글