메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
OrderBySelectλŠ” λͺ©λ‘ νŽ˜μ΄μ§€μ—μ„œ μ •λ ¬ 기쀀을 μ„ νƒν•˜λŠ” μžλ™ 생성 μ»΄ν¬λ„ŒνŠΈμž…λ‹ˆλ‹€. λ°±μ—”λ“œμ˜ OrderBy enum을 기반으둜 μƒμ„±λ˜λ©°, ν•œκΈ€ λ ˆμ΄λΈ”μ΄ μžλ™μœΌλ‘œ λ§€ν•‘λ©λ‹ˆλ‹€.

핡심 κΈ°λŠ₯

μžλ™ 생성

Entity μ •μ˜μ—μ„œ μžλ™ 생성OrderBy enum 기반

ν•œκΈ€ λ ˆμ΄λΈ”

μžλ™ λ ˆμ΄λΈ” λ§€ν•‘β€œIDμ΅œμ‹ μˆœβ€, β€œμƒμ„±μΌμ˜€λ¦„μ°¨μˆœβ€ λ“±

useListParams 톡합

register νŒ¨ν„΄ 지원URL 동기화 μžλ™

μ»€μŠ€ν„°λ§ˆμ΄μ§•

μŠ€μΊν΄λ”© 파일자유둜운 μˆ˜μ • κ°€λŠ₯

μžλ™ 생성 쑰건

OrderBySelectλŠ” λ°±μ—”λ“œμ— OrderBy enum이 μ •μ˜λ˜λ©΄ μžλ™μœΌλ‘œ μƒμ„±λ©λ‹ˆλ‹€.

λ°±μ—”λ“œ μ •μ˜

// user.types.ts (λ°±μ—”λ“œ)
import { z } from "zod";

export const UserOrderBy = z.enum([
  "id-desc",
  "id-asc",
  "created_at-desc",
  "created_at-asc",
  "username-asc",
  "username-desc",
]);

export const UserListParams = UserBaseListParams.extend({
  orderBy: UserOrderBy.optional(),
});

μžλ™ μƒμ„±λ˜λŠ” 파일

μœ„μΉ˜: web/src/components/user/UserOrderBySelect.tsx μžλ™ μƒμ„±λ˜λŠ” λ ˆμ΄λΈ”: web/src/services/sonamu.generated.ts
export const UserOrderByLabel = {
  "id-desc": "IDμ΅œμ‹ μˆœ",
  "id-asc": "ID였래된순",
  "created_at-desc": "μƒμ„±μΌμ΅œμ‹ μˆœ",
  "created_at-asc": "μƒμ„±μΌμ˜€λž˜λœμˆœ",
  "username-asc": "μ΄λ¦„μ˜€λ¦„μ°¨μˆœ",
  "username-desc": "μ΄λ¦„λ‚΄λ¦Όμ°¨μˆœ",
};
λ ˆμ΄λΈ” 생성 κ·œμΉ™
  • id-desc β†’ β€œIDμ΅œμ‹ μˆœβ€
  • created_at-asc β†’ β€œμƒμ„±μΌμ˜€λž˜λœμˆœβ€
  • username-desc β†’ β€œμ΄λ¦„λ‚΄λ¦Όμ°¨μˆœβ€
ν•„λ“œλͺ…κ³Ό μ •λ ¬ λ°©ν–₯(asc/desc)을 μ‘°ν•©ν•˜μ—¬ μžλ™ μƒμ„±λ©λ‹ˆλ‹€.

κΈ°λ³Έ μ‚¬μš©λ²•

useListParams와 ν•¨κ»˜ μ‚¬μš©

import { useListParams } from "@sonamu-kit/react-components";
import { UserOrderBySelect } from "@/components/user/UserOrderBySelect";
import { UserOrderBy } from "@/services/sonamu.generated";

export function UserListPage() {
  const { register } = useListParams(UserListParams, {
    num: 24,
    page: 1,
    orderBy: UserOrderBy.options[0],  // κΈ°λ³Έκ°’: "id-desc"
  });

  return (
    <div className="flex gap-2">
      <UserOrderBySelect {...register("orderBy")} />
    </div>
  );
}
λ™μž‘:
  1. μ‚¬μš©μžκ°€ μ •λ ¬ μ˜΅μ…˜ 선택
  2. register("orderBy")κ°€ μžλ™μœΌλ‘œ URL μ—…λ°μ΄νŠΈ
  3. API 호좜 μ‹œ listParams.orderByκ°€ 전달됨
  4. λ°±μ—”λ“œμ—μ„œ μ •λ ¬ 적용

Props

export type UserOrderBySelectProps = {
  value?: string;
  onValueChange?: (value: string | null | undefined) => void;
  placeholder?: string;
  textPrefix?: string;
  clearable?: boolean;
  disabled?: boolean;
  className?: string;
};

κΈ°λ³Έ μ‚¬μš© (register 없이)

import { useState } from "react";
import { UserOrderBySelect } from "@/components/user/UserOrderBySelect";

export function UserListPage() {
  const [orderBy, setOrderBy] = useState<string>("id-desc");

  return (
    <UserOrderBySelect
      value={orderBy}
      onValueChange={(value) => setOrderBy(value ?? "id-desc")}
      placeholder="μ •λ ¬ κΈ°μ€€"
    />
  );
}

Props 상세

placeholder

μ„ νƒλ˜μ§€ μ•Šμ•˜μ„ λ•Œ ν‘œμ‹œλ˜λŠ” ν…μŠ€νŠΈμž…λ‹ˆλ‹€.
<UserOrderBySelect
  {...register("orderBy")}
  placeholder="μ •λ ¬ 선택"  // κΈ°λ³Έκ°’: "μ •λ ¬"
/>

textPrefix

각 μ˜΅μ…˜ μ•žμ— λΆ™λŠ” μ ‘λ‘μ‚¬μž…λ‹ˆλ‹€.
<UserOrderBySelect
  {...register("orderBy")}
  textPrefix="μ •λ ¬: "
/>

// λ Œλ”λ§ κ²°κ³Ό:
// - μ •λ ¬: IDμ΅œμ‹ μˆœ
// - μ •λ ¬: ID였래된순
// - μ •λ ¬: μƒμ„±μΌμ΅œμ‹ μˆœ
textPrefix ν™œμš© μ˜ˆμ‹œλͺ©λ‘ νŽ˜μ΄μ§€ 상단에 ν˜„μž¬ 정렬을 λͺ…ν™•νžˆ ν‘œμ‹œν•˜κ³  싢을 λ•Œ μœ μš©ν•©λ‹ˆλ‹€:
[μ •λ ¬: IDμ΅œμ‹ μˆœ β–Ό]

clearable

β€œμ „μ²΄β€ μ˜΅μ…˜μ„ μΆ”κ°€ν•˜μ—¬ 정렬을 μ΄ˆκΈ°ν™”ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
<UserOrderBySelect
  {...register("orderBy")}
  clearable
/>

// λ Œλ”λ§ κ²°κ³Ό:
// - 전체           ← clearable둜 좔가됨
// - IDμ΅œμ‹ μˆœ
// - ID였래된순
// - ...
μ‚¬μš© μ‹œλ‚˜λ¦¬μ˜€:
  • κΈ°λ³Έ μ •λ ¬λ‘œ λŒμ•„κ°€κ³  싢을 λ•Œ
  • μ •λ ¬ 없이 λ°μ΄ν„°λ² μ΄μŠ€ μˆœμ„œλŒ€λ‘œ 보고 싢을 λ•Œ

disabled

μ»΄ν¬λ„ŒνŠΈλ₯Ό λΉ„ν™œμ„±ν™”ν•©λ‹ˆλ‹€.
<UserOrderBySelect
  {...register("orderBy")}
  disabled={isLoading}  // λ‘œλ”© μ€‘μ—λŠ” λ³€κ²½ λΆˆκ°€
/>

className

Tailwind CSS 클래슀λ₯Ό μΆ”κ°€ν•©λ‹ˆλ‹€.
<UserOrderBySelect
  {...register("orderBy")}
  className="w-[200px] h-8 bg-white border-gray-300 text-xs"
/>

μ‹€μ „ 예제

μ™„μ „ν•œ λͺ©λ‘ ν•„ν„°

import { useListParams } from "@sonamu-kit/react-components";
import { Input } from "@sonamu-kit/react-components/components";
import { UserService } from "@/services/services.generated";
import { UserOrderBySelect } from "@/components/user/UserOrderBySelect";
import { UserSearchFieldSelect } from "@/components/user/UserSearchFieldSelect";
import { UserRoleSelect } from "@/components/user/UserRoleSelect";

export function UserListPage() {
  const { listParams, register } = useListParams(UserListParams, {
    num: 24,
    page: 1,
    search: "id" as const,
    keyword: "",
    orderBy: "id-desc" as const,
    role: undefined,
  });

  const { data } = UserService.useUsers("A", listParams);

  return (
    <div>
      {/* ν•„ν„° μ„Ήμ…˜ */}
      <div className="flex gap-2 mb-4">
        {/* 검색 */}
        <UserSearchFieldSelect
          {...register("search")}
          className="w-32"
        />
        <Input
          {...register("keyword")}
          placeholder="검색어"
          className="w-64"
        />

        {/* μ—­ν•  ν•„ν„° */}
        <UserRoleSelect
          {...register("role")}
          clearable
        />

        {/* μ •λ ¬ */}
        <UserOrderBySelect
          {...register("orderBy")}
          textPrefix="μ •λ ¬: "
          className="w-[200px]"
        />
      </div>

      {/* ν…Œμ΄λΈ” */}
      <table>
        {/* ... */}
      </table>
    </div>
  );
}

λͺ¨λ°”일 λ°˜μ‘ν˜•

<div className="flex flex-col sm:flex-row gap-2">
  <UserOrderBySelect
    {...register("orderBy")}
    className="w-full sm:w-[200px]"  // λͺ¨λ°”일: 전체 λ„ˆλΉ„, λ°μŠ€ν¬νƒ‘: 200px
  />
</div>

μ •λ ¬ μ˜΅μ…˜ 수 ν‘œμ‹œ

import { UserOrderBy } from "@/services/sonamu.generated";

<div className="flex items-center gap-2">
  <span className="text-sm text-gray-500">
    {UserOrderBy.options.length}개 μ •λ ¬ μ˜΅μ…˜
  </span>
  <UserOrderBySelect {...register("orderBy")} />
</div>

μ»€μŠ€ν„°λ§ˆμ΄μ§•

OrderBySelectλŠ” μŠ€μΊν΄λ”© νŒŒμΌμ΄λ―€λ‘œ 자유둭게 μˆ˜μ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

λ ˆμ΄λΈ” λ³€κ²½

// UserOrderBySelect.tsx μˆ˜μ •
import { UserOrderByLabel } from "@/services/sonamu.generated";

// μ»€μŠ€ν…€ λ ˆμ΄λΈ” 객체 생성
const customLabels = {
  ...UserOrderByLabel,
  "id-desc": "μ΅œμ‹  λ“±λ‘μˆœ",  // κΈ°λ³Έ: "IDμ΅œμ‹ μˆœ"
  "created_at-asc": "였래된 순",  // κΈ°λ³Έ: "μƒμ„±μΌμ˜€λž˜λœμˆœ"
};

export function UserOrderBySelect({ ... }) {
  return (
    <Select ...>
      <SelectContent>
        {validOptions.map((key) => (
          <SelectItem key={key} value={key}>
            {(textPrefix ?? "") + customLabels[key]}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

μ•„μ΄μ½˜ μΆ”κ°€

import ArrowUpIcon from "~icons/lucide/arrow-up";
import ArrowDownIcon from "~icons/lucide/arrow-down";

const getIcon = (key: string) => {
  if (key.endsWith("-asc")) return <ArrowUpIcon className="w-3 h-3" />;
  if (key.endsWith("-desc")) return <ArrowDownIcon className="w-3 h-3" />;
  return null;
};

<SelectItem key={key} value={key}>
  <div className="flex items-center gap-2">
    {getIcon(key)}
    {customLabels[key]}
  </div>
</SelectItem>

νŠΉμ • μ˜΅μ…˜ 숨기기

// "username" μ •λ ¬ μ˜΅μ…˜λ§Œ ν‘œμ‹œ
const visibleOptions = validOptions.filter(key =>
  key.includes("username")
);

<SelectContent>
  {visibleOptions.map((key) => (
    <SelectItem key={key} value={key}>
      {UserOrderByLabel[key]}
    </SelectItem>
  ))}
</SelectContent>

κ·Έλ£Ήν™”

const groupedOptions = {
  "κΈ°λ³Έ": ["id-desc", "id-asc"],
  "λ‚ μ§œ": ["created_at-desc", "created_at-asc"],
  "이름": ["username-asc", "username-desc"],
};

<SelectContent>
  {Object.entries(groupedOptions).map(([group, options]) => (
    <Fragment key={group}>
      <SelectLabel>{group}</SelectLabel>
      {options.map((key) => (
        <SelectItem key={key} value={key}>
          {UserOrderByLabel[key]}
        </SelectItem>
      ))}
    </Fragment>
  ))}
</SelectContent>

λ°±μ—”λ“œ 처리

OrderBySelectμ—μ„œ μ„ νƒν•œ 값은 λ°±μ—”λ“œλ‘œ μ „λ‹¬λ˜μ–΄ μ‹€μ œ 정렬이 μ μš©λ©λ‹ˆλ‹€.

Modelμ—μ„œ 처리

// user.model.ts (λ°±μ—”λ“œ)
async findMany<T extends UserSubsetKey, LP extends UserListParams>(
  subset: T,
  rawParams?: LP,
): Promise<ListResult<LP, UserSubsetMapping[T]>> {
  const params = {
    num: 24,
    page: 1,
    orderBy: "id-desc" as const,
    ...rawParams,
  };

  const { qb } = this.getSubsetQueries(subset);

  // orderBy 처리
  if (params.orderBy === "id-desc") {
    qb.orderBy("users.id", "desc");
  } else if (params.orderBy === "id-asc") {
    qb.orderBy("users.id", "asc");
  } else if (params.orderBy === "created_at-desc") {
    qb.orderBy("users.created_at", "desc");
  } else if (params.orderBy === "created_at-asc") {
    qb.orderBy("users.created_at", "asc");
  } else if (params.orderBy === "username-asc") {
    qb.orderBy("users.username", "asc");
  } else if (params.orderBy === "username-desc") {
    qb.orderBy("users.username", "desc");
  } else {
    exhaustive(params.orderBy);  // νƒ€μž… μ•ˆμ „μ„± 보μž₯
  }

  return this.executeSubsetQuery({ subset, qb, params });
}

exhaustive νŒ¨ν„΄

exhaustive()λŠ” λͺ¨λ“  경우λ₯Ό μ²˜λ¦¬ν–ˆλŠ”μ§€ 컴파일 νƒ€μž„μ— κ²€μ¦ν•©λ‹ˆλ‹€.
import { exhaustive } from "sonamu";

// OrderBy enum에 μƒˆ μ˜΅μ…˜ "email-asc"λ₯Ό μΆ”κ°€ν•˜λ©΄
// 컴파일 μ—λŸ¬ λ°œμƒ β†’ κ°œλ°œμžκ°€ λˆ„λ½μ„ μ¦‰μ‹œ μ•Œ 수 있음
else {
  exhaustive(params.orderBy);  // ❌ νƒ€μž… μ—λŸ¬: "email-asc" 미처리
}

μž¬μƒμ„±

OrderBySelectλŠ” 초기 생성 ν›„ μžλ™ μž¬μƒμ„±λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.

μž¬μƒμ„±μ΄ ν•„μš”ν•œ 경우

  1. μ»΄ν¬λ„ŒνŠΈ νŒŒμΌμ„ μ‚­μ œν•œ 경우
  2. Entity의 OrderBy enum이 크게 λ³€κ²½λœ 경우

μž¬μƒμ„± 방법

# μ»΄ν¬λ„ŒνŠΈ μ‚­μ œ
rm web/src/components/user/UserOrderBySelect.tsx

# 개발 μ„œλ²„ μž¬μ‹œμž‘ (μžλ™ μž¬μƒμ„±)
pnpm dev
μž¬μƒμ„± μ‹œ μ£Όμ˜μ»€μŠ€ν„°λ§ˆμ΄μ§•ν•œ λ‚΄μš©μ€ μž¬μƒμ„± μ‹œ μ‚¬λΌμ§‘λ‹ˆλ‹€. μ€‘μš”ν•œ μˆ˜μ • 사항은 λ³„λ„λ‘œ λ°±μ—…ν•˜κ±°λ‚˜ λ¬Έμ„œν™”ν•˜μ„Έμš”.

문제 ν•΄κ²°

OrderBySelectκ°€ μƒμ„±λ˜μ§€ μ•ŠμŒ

원인: λ°±μ—”λ“œμ— OrderBy enum이 μ •μ˜λ˜μ§€ μ•ŠμŒ ν•΄κ²°:
// user.types.ts (λ°±μ—”λ“œ)에 μΆ”κ°€
export const UserOrderBy = z.enum([
  "id-desc",
  "id-asc",
]);

export const UserListParams = UserBaseListParams.extend({
  orderBy: UserOrderBy.optional(),
});

λ ˆμ΄λΈ”μ΄ ν•œκΈ€λ‘œ ν‘œμ‹œλ˜μ§€ μ•ŠμŒ

원인: sonamu.generated.tsκ°€ μž¬μƒμ„±λ˜μ§€ μ•ŠμŒ ν•΄κ²°:
# sonamu.lock μ‚­μ œ ν›„ μž¬λ™κΈ°ν™”
rm api/sonamu.lock
pnpm sonamu sync

선택해도 정렬이 μ μš©λ˜μ§€ μ•ŠμŒ

원인: λ°±μ—”λ“œ Modelμ—μ„œ orderBy 처리 λˆ„λ½ ν•΄κ²°: user.model.ts의 findManyμ—μ„œ params.orderByλ₯Ό μ²˜λ¦¬ν•˜μ„Έμš”.

κ΄€λ ¨ λ¬Έμ„œ