Sonamuμ μ€μΊν΄λ© μμ€ν
μΌλ‘ κ΄λ¦¬μ νμ΄μ§μ λͺ©λ‘κ³Ό νΌ λ·°λ₯Ό μλμΌλ‘ μμ±νλ λ°©λ²μ μμλ΄
λλ€.
View Scaffolding κ°μ
μλ μμ±
λͺ©λ‘/νΌ λ·°μΉ UIλ‘ μμ±
νμ
μμ
Entity κΈ°λ°μ»΄νμΌ νμ κ²μ¦
컀μ€ν°λ§μ΄μ§
μμ± ν μμ μμ ν μ μ΄
μΌκ΄μ±
ν΅μΌλ UI/UXλ² μ€νΈ νλν°μ€
View Scaffoldingμ΄λ?
λ¬Έμ : λ°λ³΅μ μΈ CRUD λ·° μμ±
μ ν΅μ μΈ κ°λ°μμλ κ° μν°ν°λ§λ€ λΉμ·ν CRUD νλ©΄μ μλμΌλ‘ λ°λ³΅ μμ±ν΄μΌ ν©λλ€.
μλ μμ± μ λ¬Έμ μ :
- μκ° λλΉ: λͺ©λ‘, μμ±, μμ νμ΄μ§λ₯Ό λͺ¨λ μμΌλ‘ μμ±
- μΌκ΄μ± λΆμ‘±: κ°λ°μλ§λ€ λ€λ₯Έ μ€νμΌ, λ€λ₯Έ ꡬ쑰
- μ μ§λ³΄μ μ΄λ €μ: μν°ν° λ³κ²½ μ λͺ¨λ λ·° μμ νμ
- μ€μ κ°λ₯μ±: νλ λλ½, μλͺ»λ νμ
, μ ν¨μ± κ²μ¬ λλ½
- 보μΌλ¬νλ μ΄νΈ: ν
μ΄λΈ, νΌ, λ²νΌ λ± λ°λ³΅ μ½λ
μμ: User μν°ν° νλμ νμν νμ΄μ§
/admin/users β λͺ©λ‘ νμ΄μ§ (ν
μ΄λΈ, νμ΄μ§λ€μ΄μ
, κ²μ)
/admin/users/form β μμ±/μμ νμ΄μ§ (νΌ, μ ν¨μ± κ²μ¬)
κ° νμ΄μ§λ§λ€ 100~200μ€ μ΄μμ μ½λκ° νμνκ³ , μ΄λ₯Ό λͺ¨λ μν°ν°μ λ°λ³΅ν΄μΌ ν©λλ€.
ν΄κ²°: μλ μ€μΊν΄λ©
Sonamuλ Entity μ μλ₯Ό κΈ°λ°μΌλ‘ κ΄λ¦¬μ νμ΄μ§ λ·°λ₯Ό μλ μμ±ν©λλ€.
μ₯μ :
- β¨ μ¦μ μμ±: μΉ UIλ‘ ν΄λ¦λ§μΌλ‘ λ·° μμ±
- β¨ νμ
μμ : Entity νμ
μ΄ μ»΄ν¬λνΈμ κ·Έλλ‘ λ°μ
- β¨ μΌκ΄λ UI: ν΅μΌλ λμμΈ μμ€ν
- β¨ λ² μ€νΈ νλν°μ€: κ²μ¦λ ν¨ν΄ μ μ©
- ⨠컀μ€ν°λ§μ΄μ§ κ°λ₯: μμ± ν νμν λΆλΆλ§ μμ
Sonamuμ μ€μΊν΄λ© μ² ν:
β80%λ μλμΌλ‘, 20%λ 컀μ€ν°λ§μ΄μ§β
λλΆλΆμ CRUD κΈ°λ₯μ μλ μμ±νκ³ , νΉλ³ν λΉμ¦λμ€ λ‘μ§λ§ μΆκ°λ‘ ꡬνν©λλ€.
μ€μΊν΄λ© μ€ν
Sonamu μΉ UI μ¬μ©
Sonamuλ μΉ κΈ°λ° κ΄λ¦¬ UIλ₯Ό μ 곡ν©λλ€.
# Sonamu κ°λ° μλ² μ€ν
pnpm dev
# λΈλΌμ°μ μμ Sonamu UI μ μ
# http://localhost:3000/sonamu
μΉ UIμμ λ·° μμ±:
- Sonamu UI μ μ
- βScaffoldβ λ©λ΄ μ ν
- Entity μ ν (μ: User)
- βGenerate View Listβ λ²νΌ ν΄λ¦ β
index.tsx μμ±
- βGenerate View Formβ λ²νΌ ν΄λ¦ β
form.tsx μμ±
CLI λͺ
λ Ήμ΄λ νμ¬ λΉνμ±νλμ΄ μμ΅λλ€. λ·° μμ±μ Sonamu μΉ UIλ₯Ό ν΅ν΄μλ§ κ°λ₯ν©λλ€.
μμ±λλ νμΌ κ΅¬μ‘°
web/src/routes/admin/
βββ users/
βββ index.tsx # λͺ©λ‘ νμ΄μ§ (ν
μ΄λΈ ν¬ν¨)
βββ form.tsx # μμ±/μμ ν΅ν© νΌ
μ€μ μμ±λλ νμΌ:
- index.tsx: λͺ©λ‘ ν
μ΄λΈμ΄ μ§μ ν¬ν¨λ νμ΄μ§
- form.tsx: μμ±κ³Ό μμ μ νλμ νΌμΌλ‘ ν΅ν©
μ£Όμ:
- λ³λμ μ»΄ν¬λνΈ νμΌ(UserTable.tsx, UserForm.tsx)μ μμ±λμ§ μμ
- μμΈ νμ΄μ§([id].tsx)λ μλ μμ±λμ§ μμ
- λͺ¨λ λ‘μ§μ΄ λ κ°μ νμ΄μ§ νμΌμ ν΅ν©λ¨
μμ±λ λ·° ꡬ쑰
λͺ©λ‘ νμ΄μ§ (index.tsx)
λͺ©λ‘μ ν
μ΄λΈλ‘ νμνκ³ νμ΄μ§λ€μ΄μ
, κ²μ κΈ°λ₯μ μ 곡ν©λλ€.
// web/src/routes/admin/users/index.tsx (μλ μμ±)
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { UserService } from "@/services/services.generated";
export default function UsersPage() {
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
// TanStack Query Hook μ¬μ©
const { data: users, isLoading } = UserService.useUsers("A", {
page,
pageSize: 20,
search,
});
return (
<div className="admin-page">
<div className="header">
<h1>Users</h1>
<button onClick={() => navigate("/admin/users/form")}>
Create New User
</button>
</div>
<div className="search">
<input
type="text"
placeholder="Search users..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* ν
μ΄λΈμ΄ μ§μ ν¬ν¨λ¨ */}
<table className="data-table">
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Username</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users?.map((user) => (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.email}</td>
<td>{user.username}</td>
<td>
<button onClick={() => navigate(`/admin/users/form?id=${user.id}`)}>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
{/* νμ΄μ§λ€μ΄μ
*/}
<div className="pagination">
<button disabled={page === 1} onClick={() => setPage(page - 1)}>
Previous
</button>
<span>Page {page}</span>
<button onClick={() => setPage(page + 1)}>
Next
</button>
</div>
</div>
);
}
κΈ°λ₯:
- νμ΄μ§λ€μ΄μ
- κ²μ νν°
- βCreate Newβ λ²νΌ
- βEditβ λ²νΌ (κ° ν)
- ν
μ΄λΈμ΄ νμ΄μ§μ μ§μ ν¬ν¨ (λ³λ μ»΄ν¬λνΈ μμ)
μμ±κ³Ό μμ μ νλμ νΌμΌλ‘ ν΅ν©ν©λλ€.
// web/src/routes/admin/users/form.tsx (μλ μμ±)
import { useState, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { UserService } from "@/services/services.generated";
export default function UserFormPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const userId = searchParams.get("id");
const [formData, setFormData] = useState({
email: "",
username: "",
bio: "",
});
const [saving, setSaving] = useState(false);
// μμ λͺ¨λ: κΈ°μ‘΄ λ°μ΄ν° λ‘λ
useEffect(() => {
if (userId) {
UserService.getUser("C", parseInt(userId)).then((user) => {
setFormData({
email: user.email,
username: user.username,
bio: user.bio || "",
});
});
}
}, [userId]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
if (userId) {
// μμ
await UserService.update(parseInt(userId), formData);
} else {
// μμ±
await UserService.create(formData);
}
navigate("/admin/users");
} catch (error) {
alert("Failed to save");
} finally {
setSaving(false);
}
}
return (
<div className="admin-page">
<div className="header">
<h1>{userId ? "Edit User" : "Create New User"}</h1>
<button onClick={() => navigate("/admin/users")}>
Back to List
</button>
</div>
<form onSubmit={handleSubmit} className="entity-form">
<div className="form-field">
<label>Email *</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
</div>
<div className="form-field">
<label>Username *</label>
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
required
/>
</div>
<div className="form-field">
<label>Bio</label>
<textarea
value={formData.bio}
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
rows={4}
/>
</div>
<div className="form-actions">
<button type="submit" disabled={saving}>
{saving ? "Saving..." : "Save"}
</button>
</div>
</form>
</div>
);
}
κΈ°λ₯:
- μμ±/μμ ν΅ν©: URL νλΌλ―Έν°(
?id=)λ‘ λͺ¨λ ꡬλΆ
- μμ μ κΈ°μ‘΄ λ°μ΄ν° μλ λ‘λ
- μ ν¨μ± κ²μ¬
- μ μ₯ μ€ λ²νΌ λΉνμ±ν
- βBack to Listβ λ²νΌ
μμ± νμΌμ νΉμ§
ν΅ν© ꡬ쑰
Sonamuμ μ€μΊν΄λ©μ μ΅μνμ νμΌλ‘ ꡬμ±λ©λλ€:
μ ν΅μ μΈ κ΅¬μ‘° (볡μ‘ν¨):
pages/users/
βββ index.tsx
βββ [id].tsx
βββ new.tsx
βββ [id]/edit.tsx
components/users/
βββ UserTable.tsx
βββ UserDetail.tsx
βββ UserForm.tsx
Sonamu ꡬ쑰 (κ°κ²°ν¨):
admin/users/
βββ index.tsx # ν
μ΄λΈ ν¬ν¨
βββ form.tsx # μμ±/μμ ν΅ν©
ν΅ν© ꡬ쑰μ μ₯μ :
- νμΌ μ μ΅μν
- νμΌ κ° μ΄λ κ°μ
- μ μ§λ³΄μ κ°νΈ
- μ½λ μμ§λ λμ
λΌμ°ν
ν¨ν΄
GET /admin/users β index.tsx (λͺ©λ‘)
GET /admin/users/form β form.tsx (μμ±)
GET /admin/users/form?id=1 β form.tsx (μμ )
URL νλΌλ―Έν°λ‘ μμ±/μμ λͺ¨λλ₯Ό ꡬλΆν©λλ€.
컀μ€ν°λ§μ΄μ§
μμ± ν μμ
μμ±λ νμΌμ μμ ν μμ κ°λ₯ν©λλ€.
// web/src/routes/admin/users/index.tsx (컀μ€ν°λ§μ΄μ§)
export default function UsersPage() {
// μΆκ°: μν λ³ νν°
const [role, setRole] = useState<string | null>(null);
const { data: users } = UserService.useUsers("A", {
page,
pageSize: 20,
search,
role, // 컀μ€ν
νλΌλ―Έν°
});
return (
<div>
{/* μΆκ°: μν νν° UI */}
<select value={role || ""} onChange={(e) => setRole(e.target.value || null)}>
<option value="">All Roles</option>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
{/* κΈ°μ‘΄ ν
μ΄λΈ... */}
</div>
);
}
μλ μμ±λ νΌμ κΈ°λ³Έμ μΌλ‘ useStateμ μ§μ μ΄λ²€νΈ νΈλ€λ¬λ₯Ό μ¬μ©ν©λλ€. μ΄λ₯Ό useTypeFormμΌλ‘ μ
κ·Έλ μ΄λνλ©΄ ν¨μ¬ κ°κ²°νκ³ νμ
μμ ν μ½λκ° λ©λλ€.
μμ±λ μ½λ (κΈ°λ³Έ):
// admin/users/form.tsx (μλ μμ±)
const [formData, setFormData] = useState({
email: "",
username: "",
bio: "",
});
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
try {
if (userId) {
await UserService.update(parseInt(userId), formData);
} else {
await UserService.create(formData);
}
navigate("/admin/users");
} catch (error) {
alert("Failed to save");
} finally {
setSaving(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
{/* ... */}
</form>
);
useTypeFormμΌλ‘ μ
κ·Έλ μ΄λ:
// admin/users/form.tsx (κ°μ )
import { useTypeForm } from "@sonamu-kit/react-components/lib";
import { Input, Button } from "@sonamu-kit/react-components/components";
import { UserSaveParams } from "@/services/user/user.types";
const { register, submit, errors } = useTypeForm(UserSaveParams, {
email: "",
username: "",
bio: "",
});
const saveMutation = UserService.useSaveMutation();
const handleSubmit = submit(async (form) => {
saveMutation.mutate(
{ spa: [form] },
{
onSuccess: () => navigate("/admin/users"),
onError: () => alert("Failed to save"),
}
);
});
return (
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<div>
<Input {...register("email")} placeholder="Email" />
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
{/* ... */}
<Button type="submit" disabled={saveMutation.isPending}>
{saveMutation.isPending ? "Saving..." : "Save"}
</Button>
</form>
);
κ°μ ν¨κ³Ό:
- β
μ½λλ 50% κ°μ: useState, μ΄λ²€νΈ νΈλ€λ¬ μ κ±°
- β
μλ μ ν¨μ± κ²μ¬: SaveParams μ€ν€λ§ κΈ°λ°
- β
νμ
μμ : μ»΄νμΌ νμ μλ¬ κ°μ§
- β
μλ¬ μ²λ¦¬ ν΅ν©: νλλ³ μλ¬ λ©μμ§ μλ νμ
μλ μμ±λ νμΌμ Gitμ 컀λ°ν ν useTypeFormμΌλ‘ μ
κ·Έλ μ΄λνλ©΄, λ³κ²½ μ¬νμ μ½κ² μΆμ ν μ μμ΅λλ€.
λ³λ μ»΄ν¬λνΈλ‘ λΆλ¦¬
νμνλ©΄ μλμΌλ‘ μ»΄ν¬λνΈλ₯Ό λΆλ¦¬ν μ μμ΅λλ€:
// components/users/UserTable.tsx (μλ μμ±)
export function UserTable({ users }: { users: User[] }) {
return (
<table className="data-table">
{/* ν
μ΄λΈ λ΄μ© */}
</table>
);
}
// admin/users/index.tsx (μμ )
import { UserTable } from "@/components/users/UserTable";
export default function UsersPage() {
const { data: users } = UserService.useUsers("A", { page, pageSize: 20 });
return (
<div>
<UserTable users={users || []} />
</div>
);
}
μμΈ νμ΄μ§ μΆκ°
μλ μμ±λμ§ μλ μμΈ νμ΄μ§λ μλμΌλ‘ μΆκ°:
// admin/users/[id].tsx (μλ μμ±)
import { useParams } from "react-router-dom";
import { UserService } from "@/services/services.generated";
export default function UserDetailPage() {
const { id } = useParams();
const { data: user } = UserService.useUser("C", parseInt(id!));
return (
<div>
<h1>{user?.username}</h1>
<p>{user?.email}</p>
<p>{user?.bio}</p>
</div>
);
}
λ² μ€νΈ νλν°μ€
1. μμ± ν μ¦μ Git 컀λ°
# Sonamu UIμμ λ·° μμ±
git add web/src/routes/admin/users/
git commit -m "feat: scaffold User views"
μ΄λ κ² νλ©΄ 컀μ€ν°λ§μ΄μ§ λ΄μμ μΆμ ν μ μμ΅λλ€.
2. κ³΅ν΅ λ‘μ§μ HookμΌλ‘ μΆμΆ
// hooks/useEntityList.ts
export function useEntityList<T>(
entityName: string,
useHook: any
) {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const { data, isLoading } = useHook("A", {
page,
pageSize: 20,
search,
});
return { data, isLoading, page, setPage, search, setSearch };
}
3. μ€νμΌμ μ μμΌλ‘ κ΄λ¦¬
/* styles/admin.css */
.admin-page { }
.data-table { }
.entity-form { }
λͺ¨λ κ΄λ¦¬μ νμ΄μ§μμ μΌκ΄λ μ€νμΌ μ¬μ©.
μ£Όμμ¬ν
View Scaffolding μ¬μ© μ μ£Όμμ¬ν:
- μΉ UIμμλ§ μμ± κ°λ₯ (CLI λͺ
λ Ήμ΄ λΉνμ±νλ¨)
- index.tsx, form.tsxλ§ μμ± (λ³λ μ»΄ν¬λνΈ μμ)
- μμΈ νμ΄μ§λ μλ μΆκ° νμ
- μ¬μμ±νλ©΄ 컀μ€ν°λ§μ΄μ§ μμ€ (Git μ»€λ° νμ)
- μμ± ν νμμ λ°λΌ μ»΄ν¬λνΈ λΆλ¦¬ κΆμ₯
λ€μ λ¨κ³