사람함께에이전트▾
사람 — 직접 정하고 책임지는 비즈니스 규칙과 흐름. 직접 읽어보세요.
함께 — 개념은 알아두고, 세부 규칙은 에이전트가 따릅니다.
에이전트 — 에이전트가 따르는 규칙과 레퍼런스. 필요할 때 찾아보세요.
앱 & 라이브러리▾
도메인▾
스칼라▾
model.Template.tsx
<Model>.Template.tsx는 모듈의 폼입니다. 레코드 하나를 만들거나 고칠 때 사람이 채우는 필드를 그립니다. 대부분 폼 전체를 export하지만, 제출 버튼이나 온보딩 단계, 미리보기 블록 같은 작은 조각을 export해도 됩니다.Template은 화면을 store에 연결하는 일만 합니다. 판단이 필요한 일은 모두 다른 곳에 둡니다:
할 일
Template
*.Template.tsx
다른 곳
폼 그리기
Field.*
✓
라벨이 붙은 컨트롤로, 각각 폼 초안의 필드 하나에 묶입니다.
제출 버튼 · 단계 · 미리보기
✓
폼 하나에 딸린 작은 인터랙션 조각입니다.
l("<model>.<field>")
✓
모듈 dictionary에서 가져온 라벨과 도움말입니다.
판단과 저장
비즈니스 규칙
✓
검증과 상태 전이는 constant, document, service에 둡니다.
권한 확인
✓
누가 저장할 수 있는지는 signal의 guard가 정합니다.
fetch.*
✓
서버 호출과 그 전후의 토스트는 store 액션에 둡니다.
열기 · 불러오기 · 제출
✓
Template을 감싼 편집 셸이 맡습니다.
✓여기에 둡니다여기에 두지 않습니다
이 페이지에서 쓰는 말
용어설명
<model>Form
store가 들고 있는, 편집 중인 레코드의 초안입니다. 예를 들면
ticketForm입니다.st.do.set<Field>On<Model>
store가 필드마다 자동으로 만드는 setter입니다. 예를 들면
setTitleOnTicket입니다.fetch.slice.<name>
Field나 셸에게 어떤 모델의 어떤 목록을 다루는지 알려 줍니다.
편집 셸
Load.Edit, Model.Edit처럼 폼을 불러오고, 열고, 제출하는 래퍼입니다.파일 규칙
Template은 편집할 모델과 같은 모듈 폴더에 둡니다. 모든 필드가 브라우저에만 있는 store를 읽고 쓰므로, 첫 줄은 언제나
"use client"입니다.경로
apps/<app>/lib/<model>/<Model>.Template.tsx데이터베이스 모듈과 스칼라 모듈에 둘 수 있고, 서비스 모듈에는 두지 않습니다.
첫 줄
"use client";언제나 import보다 먼저, 파일 첫 줄에 씁니다.
export
General · Phone · SubmitPhone · PhoneCode이름 있는 화살표 함수 컴포넌트입니다. General이 모델의 기본 폼입니다.
쓰는 모양
<Ticket.Template.General />page와 셸은 @apps/<app>/client의 모델 네임스페이스로 가져다 씁니다.
- 기본 폼의 이름은
General로 합니다.Model.AdminPanel은Template.General을 폼으로 쓰고, 없으면 첫 번째 export를 씁니다. - Template에는
useState를 쓰지 않습니다. 폼 값은 store에 둡니다. Template 안의useState는akan quality ssr이akan.ssr.template-client-state경고로 잡아냅니다.
기본 폼 Template
기본 폼은 store에서 초안을 읽고, 라벨은 dictionary에서 가져오며, 필드마다 자동 생성된 setter로 값을 씁니다:
apps/koyo/lib/ticket/Ticket.Template.tsx
st.use.ticketForm()이 초안을 읽습니다. 초안이 바뀔 때마다 폼이 다시 렌더링됩니다.label과desc는 dictionary key입니다.desc는 라벨 옆 도움말 툴팁으로 보입니다.st.do.setTitleOnTicket은 자동으로 생깁니다. store가 필드마다 setter를 하나씩 만들어 두므로 그대로 넘기기만 하면 됩니다.Layout.Template이 필드 간격을 고르게 맞춥니다. 필요하면 호출하는 쪽에서className으로 조정합니다.


setter는 화살표 함수로 감싸지 말고 참조로 넘기세요.
onChange={(v) => st.do.setTitleOnTicket(v)}도 사람에게는 똑같이 동작하지만, 그 필드가 인페이지 에이전트에게 공개되지 않고 lint(no-unpublished-form-setter)에도 걸립니다. 값을 다듬으려면 Field의 transform prop을, 다른 필드까지 함께 쓰려면 store에 _postSet<Field> 메서드를 두세요.Field 고르기
Field.* 컴포넌트는 라벨 행까지 갖춰 미리 만들어 둔 폼 컨트롤입니다. 모델 필드에 맞는 것을 고르고 value와 onChange를 store에 연결합니다.| 모델 필드 | Field |
|---|---|
| ↳ 참고 | |
| String | Field.Text |
특수한 텍스트에는 TextArea, Email, Phone, Password를 씁니다. | |
| Int · Float | Field.Number |
범위처럼 숫자 두 개를 한 줄에 받을 때는 DoubleNumber를 씁니다. | |
| Boolean | Field.Switch |
| 라벨이 붙은 켜고 끄는 토글입니다. | |
| Date | Field.Date |
showTime을 켜면 시각까지 받고, 기간은 DateRange로 받습니다. | |
| enumOf(...) | Field.ToggleSelect |
값마다 번역된 라벨이 붙고, 배열이면 MultiToggleSelect를 씁니다. | |
| [String] | Field.Tags |
순서가 중요하면 행을 드래그할 수 있는 TextList를 씁니다. | |
| 다른 모델과의 관계 | Field.Parent |
배열이면 Children, ID 필드면 ParentId / ChildrenId를 씁니다. | |
| File | Field.Img |
[File]이면 Imgs, 이미지가 아니면 File / Files를 쓰며 모두 @libs/shared/ui에 있습니다. | |
| 서식 있는 본문 | Field.Rich |
첨부 파일을 넣을 수 있는 리치 텍스트 에디터이며, @libs/shared/ui에 있습니다. | |
| 내장 객체 목록 | Field.List |
| 행 하나만 그리면 추가·삭제 버튼은 필드가 그립니다. | |
기본 멤버는
akanjs/ui에 있습니다. @libs/shared/ui의 Field는 이를 모두 담고 Rich, Img, Imgs, File, Files, Coordinate, Postcode를 더하므로 여기서 가져옵니다. 아래는 value와 onChange 말고도 prop이 더 필요한 필드 네 개를 같은 폼에 추가한 모습입니다:apps/koyo/lib/ticket/Ticket.Template.tsx
Field.Parent는 관련 모델 하나를 고릅니다. 선택지는slice의 목록에서 오고,renderOption이 각 선택지를 그립니다.Field.ToggleSelect는items에enumOf클래스를 받습니다. 값마다 번역된 버튼이 하나씩 생깁니다.Field.Img는slice를 통해 업로드합니다. 올라간File을 폼에 담고,nullable을 주면 라벨에 선택 항목 표시가 붙습니다.Field.Rich에는 폼 안의 필드 key인valuePath가 꼭 필요합니다.addFile은 에디터에서 올린 파일을 받으며, 여기서는[File]필드에 자동 생성된add<Field>On<Model>을 넘겼습니다.
맞는 Field가 없으면
akanjs/ui의 Input이나 버튼, 앱 전용 컴포넌트를 써도 괜찮습니다. 다만 모델 필드에 맨 <input>을 쓰지는 않습니다. 모든 멤버의 prop 목록은 이 페이지 끝에 연결한 "폼 컨트롤" 문서에 있습니다.컴포넌트 나누기
Template 하나가 작은 컴포넌트 여러 개를 export해도 됩니다. 큰 폼은 모든 것을
General에 몰아넣지 말고, 업무 단계나 UI 역할에 따라 나눕니다.libs/shared의 user 모듈은 휴대폰 가입 단계를 입력칸과 인증번호를 보내는 버튼으로 나눕니다. 핵심만 간추리면 다음과 같습니다:libs/shared/lib/user/User.Template.tsx
- 조각마다 export를 하나씩 둡니다. page는
<User.Template.Phone />과<User.Template.SubmitPhone />을 레이아웃에 맞는 자리에 따로 놓습니다. - 조각끼리는 store로 상태를 나눕니다. 둘 다
st.use.phone()을 읽으므로 서로 prop을 주고받지 않습니다. - setter는 여기서도 참조로 넘깁니다. store의
setPhone이 번호 형식을 직접 맞추므로 입력칸에 래퍼가 필요 없습니다. - 인자가 필요한 호출은 화살표 함수로 씁니다.
onPressEnter와onClick은userId와phone을 store 액션에 넘기고, 결과를 기다리지 않도록void를 붙입니다.
Template 여는 법
Template은 필드를 그리기만 합니다. 폼 상태를 채우고, 폼을 열고, 제출하는 일은 Template을 감싼 편집 셸이 합니다. 셸은 폼이 열리는 자리에 따라 고릅니다:
| 셸 | 이럴 때 씁니다 | 그리는 것 |
|---|---|---|
| Load.Edit | page가 편집할 레코드나 새 폼의 일부 값을 이미 가지고 있을 때 | type에 따라 page 안의 폼이나 모달, 또는 필드만 그립니다. |
| Model.Edit | 목록의 행, 드롭다운, Unit에 편집 버튼이 필요할 때 | 편집 버튼(또는 넘긴 trigger)과 편집 모달입니다. |
| Model.New | 화면에 레코드를 새로 만드는 버튼이 필요할 때 | 새로 만들기 버튼(또는 넘긴 trigger)과 폼 모달입니다. |
| Model.NewWrapper | 빈 목록 안내 버튼처럼 아무 요소나 새 폼을 열어야 할 때 | 트리거만 그리므로 Model.EditModal과 짝지어 씁니다. |
- 셸이 초안을 보관하므로, 폼 값을 직접 저장하지 마세요. 사용자가 입력하는 동안 폼을 저장해 두었다가 다음에 열 때 되돌려 줍니다. 끄려면
draft={false}를 넘기고, 레코드 id나 시작 값만으로 구분되지 않는 폼이면draft="<scope>"로 범위를 직접 정합니다.
page에서 Load.Edit 쓰기
page가 무엇을 편집할지 이미 알고 있으면
Load.Edit을 씁니다. 안에 든 Template은 그대로 클라이언트 컴포넌트입니다. 새 레코드라면 edit에 일부 값만 채운 모델을 넘깁니다:apps/koyo/page/ticket/new.tsx
이미 있는 레코드를 고칠 때는
fetch.edit<Model>로 edit 객체를 가져와 대신 넘깁니다:apps/koyo/page/ticket/[ticketId]/edit.tsx
type이 폼이 나타날 자리를 정합니다."form"이면 page 안에 제출 버튼과 함께,"empty"면 필드만 그립니다. 기본값"modal"이면 모달로 그립니다.onSubmit과onCancel은"back","reset", 경로 중 하나를 받습니다. 경로 안의[ticketId]는 저장된 레코드의 id로 바뀝니다.edit에는 await하지 않은 promise를 넘겨도 됩니다.const { ticketEdit } = fetch.editTicket(ticketId)로 넘기면 스트리밍되고, 도착할 때까지 skeleton(또는 넘긴loading)이 보입니다.
Template이 렌더링되기 전에 Load.Edit이 store에 쓰는 key는 다음과 같습니다:
| 상태 key | edit 객체를 넘기면 | 부분 폼을 넘기면 |
|---|---|---|
| <model> | edit 객체로 만든 full 모델입니다. | null |
| <model>Loading | false | 그대로 둡니다. |
| <model>Form | 모델을 복사한 편집용 폼입니다. | 기본값에 edit을 합친 폼입니다. |
| <model>FormLoading | false | false |
| <model>Modal | modal prop, 없으면 "edit"입니다. | modal prop, 없으면 "edit"입니다. |
| <model>ViewAt | 서버가 레코드를 읽은 시각으로, 오래된 값이면 다시 읽는 데 씁니다. | 그대로 둡니다. |
편집 모달은 Model.Edit
목록의 행, 드롭다운, Unit에 편집 버튼이 필요하면
Model.Edit을 씁니다. 버튼과, Template을 담은 모달을 함께 그립니다:apps/koyo/lib/ticket/Ticket.Util.tsx
- 클릭하면 레코드를 불러옵니다. 버튼이
st.do.editTicket(ticketId)를 호출해 레코드를 폼에 채우고 모달을 엽니다. renderTitle="title"은 모달 제목을 정합니다. 모델 이름과 폼의title값을 씁니다. 기본 편집 버튼 대신 다른 요소를 쓰려면trigger를 넘깁니다.
새 폼 열기는 Model.NewWrapper
Model.NewWrapper는 아무 요소나 새 폼을 여는 버튼으로 만듭니다. 트리거만 그리므로, Template은 같은 slice의 Model.EditModal이 그립니다:apps/koyo/lib/ticket/Ticket.Zone.tsx
partial이 폼의 시작 값이 됩니다. 클릭하면 자동 생성된st.do.newTicket()이 이 값으로 폼을 채웁니다.Model.New는 이 한 쌍을 컴포넌트 하나로 묶은 것입니다. 트리거와 모달이 서로 다른 자리에 있을 때Model.NewWrapper를 씁니다.
한눈에 보는 규칙
앞에서 다룬 내용을 Template을 마무리하기 전에 확인할 목록으로 모았습니다:
- 첫 줄에는 언제나
"use client"를 씁니다. Template은 브라우저에만 있는 store를 읽습니다. - 필드는
Layout.Template으로 감쌉니다. 그래야 모든 폼의 간격이 같습니다. - 라벨은 모두 dictionary에서 가져옵니다.
label={l("ticket.title")},desc={l("ticket.title.desc")}처럼 쓰고, 문구를 직접 적지 않습니다. - 자동 생성 setter는 참조로 넘깁니다. 값을 다듬을 때는 setter를 화살표 함수로 감싸지 말고 Field의
transform을 씁니다. - 폼 값은
useState가 아니라 store에 둡니다. 폼은st.use.<model>Form()으로 읽습니다. - 맞는 Field가 없으면 일반 컨트롤을 씁니다.
Input, 버튼, 직접 만든 컴포넌트 모두 괜찮지만, 모델 필드에 맨<input>을 쓰지는 않습니다. - 비즈니스 판단은 Template 밖에 둡니다. constant, document, service, signal, store 액션으로 옮기고, Template에서는
fetch.*를 부르지 않습니다. - 큰 폼은 이름 있는 컴포넌트로 나눕니다.
General,Phone,SubmitPhone처럼 나눕니다. - 폼은 셸이 열게 합니다. 데이터가 있는 page에는
Load.Edit, 편집 모달에는Model.Edit, 새 폼 버튼에는Model.New나Model.NewWrapper를 씁니다.
이어서 읽기