diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs
index 9fd4fdb..6cd78b5 100644
--- a/.dependency-cruiser.cjs
+++ b/.dependency-cruiser.cjs
@@ -23,6 +23,14 @@ module.exports = {
from: { path: "^src/presentation/(?!adapters/query)" },
to: { path: "^(src/(adapters|bootstrap)|@tanstack)" },
},
+ {
+ name: "page-templates-own-layout-only",
+ severity: "error",
+ from: { path: "^src/presentation/templates" },
+ to: {
+ path: "^(src/(application|adapters|bootstrap)|src/presentation/adapters|@tanstack)",
+ },
+ },
{
name: "adapters-do-not-know-presentation",
severity: "error",
diff --git a/artifacts/tests/a11y-manual/REFERENCE_RESOURCE_DETAIL.md b/artifacts/tests/a11y-manual/REFERENCE_RESOURCE_DETAIL.md
new file mode 100644
index 0000000..81ebaae
--- /dev/null
+++ b/artifacts/tests/a11y-manual/REFERENCE_RESOURCE_DETAIL.md
@@ -0,0 +1,18 @@
+# REFERENCE_RESOURCE_DETAIL accessibility review
+
+Status: pending-manual-review
+Route ID: REFERENCE_RESOURCE_DETAIL
+Release ID:
+Reviewer:
+Reviewed at:
+Signature:
+Attestation: pending
+M1 Keyboard: pending
+M2 Visible focus: pending
+M3 Route focus: pending
+M4 Modal focus: not-applicable (no modal on this route)
+M5 Error association: not-applicable (no form error on this route)
+M6 Color signal: pending
+M7 Reduced motion: pending
+Screen reader: pending
+Notes: Human review pending.
diff --git a/artifacts/tests/a11y-manual/REFERENCE_RESOURCE_FORM.md b/artifacts/tests/a11y-manual/REFERENCE_RESOURCE_FORM.md
new file mode 100644
index 0000000..e78d185
--- /dev/null
+++ b/artifacts/tests/a11y-manual/REFERENCE_RESOURCE_FORM.md
@@ -0,0 +1,18 @@
+# REFERENCE_RESOURCE_FORM accessibility review
+
+Status: pending-manual-review
+Route ID: REFERENCE_RESOURCE_FORM
+Release ID:
+Reviewer:
+Reviewed at:
+Signature:
+Attestation: pending
+M1 Keyboard: pending
+M2 Visible focus: pending
+M3 Route focus: pending
+M4 Modal focus: pending
+M5 Error association: pending
+M6 Color signal: pending
+M7 Reduced motion: pending
+Screen reader: pending
+Notes: Human review pending.
diff --git a/artifacts/tests/a11y-manual/REFERENCE_RESOURCE_LIST.md b/artifacts/tests/a11y-manual/REFERENCE_RESOURCE_LIST.md
index dede1df..74e9ae6 100644
--- a/artifacts/tests/a11y-manual/REFERENCE_RESOURCE_LIST.md
+++ b/artifacts/tests/a11y-manual/REFERENCE_RESOURCE_LIST.md
@@ -1,4 +1,4 @@
-# SAMPLE_RESOURCE_LIST accessibility review
+# REFERENCE_RESOURCE_LIST accessibility review
Status: pending-manual-review
Route ID: REFERENCE_RESOURCE_LIST
diff --git a/artifacts/tests/a11y-manual/REFERENCE_RESOURCE_STATUS.md b/artifacts/tests/a11y-manual/REFERENCE_RESOURCE_STATUS.md
new file mode 100644
index 0000000..93e37c4
--- /dev/null
+++ b/artifacts/tests/a11y-manual/REFERENCE_RESOURCE_STATUS.md
@@ -0,0 +1,18 @@
+# REFERENCE_RESOURCE_STATUS accessibility review
+
+Status: pending-manual-review
+Route ID: REFERENCE_RESOURCE_STATUS
+Release ID:
+Reviewer:
+Reviewed at:
+Signature:
+Attestation: pending
+M1 Keyboard: pending
+M2 Visible focus: pending
+M3 Route focus: pending
+M4 Modal focus: not-applicable (no modal on this route)
+M5 Error association: not-applicable (no form error on this route)
+M6 Color signal: pending
+M7 Reduced motion: pending
+Screen reader: pending
+Notes: Human review pending.
diff --git a/config/ci/gates.json b/config/ci/gates.json
index 8104985..9a13e71 100644
--- a/config/ci/gates.json
+++ b/config/ci/gates.json
@@ -80,7 +80,8 @@
{ "script": "check:types:fixture:application-output", "expect": "fail" },
{ "script": "check:types:fixture:application-input", "expect": "fail" },
{ "script": "check:types:fixture:async-overlay", "expect": "fail" },
- { "script": "check:types:fixture:route-runtime", "expect": "fail" }
+ { "script": "check:types:fixture:route-runtime", "expect": "fail" },
+ { "script": "check:types:fixture:page-action", "expect": "fail" }
],
"logPath": "artifacts/quality/check-types.txt",
"evidence": ["artifacts/quality/check-types.txt"],
diff --git a/config/contracts/registry-governance.json b/config/contracts/registry-governance.json
index ed7db13..22990e2 100644
--- a/config/contracts/registry-governance.json
+++ b/config/contracts/registry-governance.json
@@ -8,12 +8,15 @@
"owner": "feature-routing-navigation-guard-contract",
"uniqueFields": ["routeId", "path", "chunkId"],
"allowedValues": {
- "paramsSchema": [null, "NotFoundSplat"],
+ "paramsSchema": [null, "NotFoundSplat", "ReferenceResourceParams"],
"searchSchema": [null, "ReferenceResourceListQuery"],
"loadingSurface": [
"app-shell",
"example-page",
"reference-resource-list",
+ "reference-resource-detail",
+ "reference-resource-form",
+ "reference-resource-status",
"none"
],
"errorSurface": [
@@ -27,6 +30,9 @@
"route-examples-states",
"route-examples-auth",
"route-reference-resources",
+ "route-reference-resource-detail",
+ "route-reference-resource-form",
+ "route-reference-resource-status",
"route-not-found"
]
},
@@ -67,9 +73,12 @@
"state-gallery-page",
"auth-example-page",
"reference-resource-page",
+ "reference-resource-detail-page",
+ "reference-resource-form-page",
+ "reference-resource-status-page",
"not-found-page"
],
- "paramsCodec": ["none", "NotFoundSplat"],
+ "paramsCodec": ["none", "NotFoundSplat", "ReferenceResourceParams"],
"searchCodec": ["none", "ReferenceResourceListQuery"]
},
"references": [
diff --git a/docs/architecture/decisions/VD-04-native-form-facade.md b/docs/architecture/decisions/VD-04-native-form-facade.md
new file mode 100644
index 0000000..374fd20
--- /dev/null
+++ b/docs/architecture/decisions/VD-04-native-form-facade.md
@@ -0,0 +1,55 @@
+# VD-04: Native form controller와 local facade
+
+- 상태: Accepted
+- 결정일: 2026-07-26
+- 적용 브랜치: `feature-frontend-form-page-platform`
+
+## 배경
+
+플랫폼에는 Zod가 이미 설치돼 있지만 form state, field error, dirty navigation과
+page template 계약은 없었다. React Hook Form과 resolver를 바로 추가하면
+dependency와 lockfile이 바뀌고, 현재 reference form에 필요하지 않은 복합 비동기
+field orchestration까지 플랫폼 기본값으로 고정하게 된다.
+
+## 결정
+
+1. RP-06은 React native form event와 controlled value를 사용하는 local
+ `useAppForm` facade를 기본 엔진으로 채택한다.
+2. Zod presentation schema, application command mapper와 domain invariant는 서로
+ 다른 소유물로 유지한다.
+3. page와 feature는 `useAppForm`, `Form`, `FormField`, `ErrorSummary`,
+ `mapValidationFailureToFields`, `useDirtyNavigationGuard`만 사용한다.
+4. 422 details는 승인된 `path`와 `code`만 HTTP 경계에서 투영한다. backend
+ message와 알 수 없는 field는 field에 전달하지 않고 안전한 form-level
+ error로 이동한다.
+5. 409 conflict는 validation으로 바꾸지 않으며 입력과 dirty 상태를 보존한다.
+6. pending submit은 동일 controller에서 한 번만 실행하고 success/reset 이후
+ dirty 상태를 해제한다.
+7. `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage`, `StatusPage`는
+ layout과 state slot만 소유하며 application/query/HTTP를 import하지 않는다.
+
+## React Hook Form 도입 조건
+
+다음 중 하나가 실제 제품 요구로 확인되면 local facade 내부 adapter로
+React Hook Form과 Zod resolver를 평가한다.
+
+- 동적 field array와 중첩 object를 함께 다루는 복합 form
+- field 단위 비동기 validation 취소와 의존 validation
+- 수백 개 field의 render isolation이 측정 가능한 병목인 경우
+- uncontrolled input 또는 vendor extension이 필요한 경우
+
+도입하더라도 이 문서의 public API와 component/application tests를 유지해야
+한다. vendor package를 feature/page에서 직접 import하는 것은 허용하지 않는다.
+
+## 검증과 rollback
+
+- client validation, transform/default, 422 allowlist, conflict, duplicate submit,
+ reset, dirty guard와 focus를 component test로 검증한다.
+- template 최소/전체 slot과 async/status variation을 component test로 검증한다.
+- architecture gate가 template의 application/HTTP/query vendor import를
+ 거절한다.
+- secret-like input이 URL, storage, diagnostics에 복제되지 않는지 검증한다.
+
+rollback 시 reference page는 이전 직접 form/layout으로 돌아갈 수 있다.
+application input과 outbound gateway 계약은 유지되며, form facade와 template
+commit은 독립적으로 되돌릴 수 있다.
diff --git a/docs/architecture/frontend-platform-capability-review.md b/docs/architecture/frontend-platform-capability-review.md
index 96cd248..e6e3ac1 100644
--- a/docs/architecture/frontend-platform-capability-review.md
+++ b/docs/architecture/frontend-platform-capability-review.md
@@ -67,18 +67,18 @@ recovery 계약, 제거 가능한 reference 수직 슬라이스는 구현됐다.
| HTTP client | 준비됨 | path/search/body projection, runtime timeout/retry, abort/cleanup test | feature gateway 뒤에서 사용 |
| retry | 준비됨 | HTTP 단일 소유, runtime max attempts, Query retry off | RP-09에서 telemetry 연결 |
| 오류 모델 | 부분 준비 | error registry와 normalization 존재 | typed discriminated union과 계층별 mapper |
-| 검증 | 부분 준비 | runtime/API Zod parse 결과를 실제 request에 사용 | route/form/domain 경계를 추가 |
+| 검증 | 준비됨 | runtime/API/route/form Zod parse 결과를 실행 경계에서 사용하고 domain invariant와 분리 | feature별 schema 소유권 유지 |
| 인증 연동 | 준비됨/프로젝트 선택 | opaque auth owner와 demo seam 존재 | 인증 방식별 recipe; 기본 token 저장소는 추가하지 않음 |
| 서버 상태 | 준비됨 | reference route의 query/mutation, cancellation, stale, optimistic/conflict/rollback | feature별 query contribution recipe 유지 |
| 클라이언트 상태 | 부분 준비 | local state, theme context, session external store | 상태 소유권 표와 typed external-store 예제 |
| 범용 global store | 프로젝트 선택 | 별도 라이브러리 없음 | 필요 조건에 따라 Zustand/Redux Toolkit/state machine 선택 |
| 라우팅 | 준비됨 | Data Router, typed runtime map, codec, metadata consumer, bounded chunk recovery | reference feature route와 release E2E로 사용 범위 확장 |
| 앱 셸·반응형 | 부분 준비 | header/sidebar/content/theme 구현 | 접근 가능한 mobile drawer와 focus 복원 |
-| 페이지 템플릿 | 미제공 | 각 페이지가 직접 레이아웃 조립 | list/detail/form/status 등 슬롯 기반 템플릿 |
+| 페이지 템플릿 | 준비됨 | Standard/Collection/Detail/Form/Status slot 계약과 reference 소비 화면 | RP-07 public design-system entry로 이동 |
| 디자인 토큰 | 부분 준비 | semantic color/theme 토큰 존재 | typography, spacing, motion, layer 등 3단계 토큰 |
| 공통 UI | 부분 준비 | Button, TextField, Card, Alert, Badge, Dialog | form/navigation/overlay/data/layout primitives 확장 |
| 아이콘 | 미제공 | 문자 기호를 직접 사용 | Lucide를 로컬 icon facade 뒤에서 사용 |
-| 폼 | 미제공 | 수동 문자열 검증 예제만 존재 | schema 기반 form facade와 422/dirty/pending 정책 |
+| 폼 | 준비됨 | Zod 기반 local facade, error summary/focus, 422 allowlist, dirty/pending/conflict 정책 | 복합 form 요구가 생기면 VD-04 조건으로 vendor adapter 평가 |
| 국제화 | 미제공 | 한국어 문자열·locale이 하드코딩 | typed message/formatter/locale/RTL 경계 |
| logging/diagnostics | 미제공 | telemetry port는 있으나 logger 없음 | redaction이 적용된 diagnostics/logging 경계 |
| telemetry | 부분 준비 | registry, queue, redaction 존재 | HTTP·boot·cache·storage·route 사건에 실제 연결 |
@@ -236,8 +236,8 @@ known vulnerability, license policy, SBOM/provenance를 pinned tool로 검사해
### 5.2 P1: 공통 플랫폼 기본 제공 항목
-- schema 기반 form facade와 field/error/pending/dirty/422 정책
-- standard, collection, detail, form, status page template
+- RP-06에서 완료한 schema 기반 form facade와 field/error/pending/dirty/422 정책 유지
+- RP-06에서 완료한 standard, collection, detail, form, status page template의 public entry 정리
- 접근 가능한 drawer, menu, popover, select 같은 interaction primitive
- token → primitive → pattern → template로 이어지는 디자인 시스템
- Lucide를 감싼 local icon registry와 `IconButton`
diff --git a/docs/architecture/frontend-platform-implementation-roadmap.md b/docs/architecture/frontend-platform-implementation-roadmap.md
index 5423921..27d5abd 100644
--- a/docs/architecture/frontend-platform-implementation-roadmap.md
+++ b/docs/architecture/frontend-platform-implementation-roadmap.md
@@ -508,6 +508,22 @@ RP-06은 기존 reference controls/layout으로 돌아가도 controller/applicat
경계가 유지돼야 한다. vendor adapter, form contract와 template commit을 구분해
부분 revert가 가능하게 한다.
+**구현 증거 (2026-07-26)**
+
+- VD-04에서 dependency 추가 없는 native controller + Zod local facade를
+ 채택했고 vendor 도입 조건을 문서화했다.
+- `src/presentation/forms`가 field registration, parse/error map,
+ dirty/touched/reset/pending/result, 422 allowlist, first-error focus,
+ duplicate submit과 dirty navigation을 제공한다.
+- `src/presentation/templates`가 다섯 page 유형의 slot/landmark/responsive
+ 계약을 제공하며 architecture negative fixture가 application/vendor import를
+ 거절한다.
+- reference list/detail/create/status route가 네 구체 template를 사용하고
+ production composition test가 list → form → command → HTTP → invalidation →
+ list 경로를 실행한다.
+- component/integration/E2E test가 validation, transform, 422, conflict,
+ secret 비노출, navigation focus와 320px reflow를 검증한다.
+
### 07. `feature-frontend-design-system-platform`
**목표**
diff --git a/docs/architecture/frontend-ports-adapters-and-boundaries.md b/docs/architecture/frontend-ports-adapters-and-boundaries.md
index 39ee046..c4b857a 100644
--- a/docs/architecture/frontend-ports-adapters-and-boundaries.md
+++ b/docs/architecture/frontend-ports-adapters-and-boundaries.md
@@ -163,6 +163,14 @@ contract/presentation은 `src/features/reference-feature`가 소유하고, gener
installed catalog만 bootstrap과 router에 노출된다. 제거 gate는 feature와 test를
삭제한 복제본에서 전체 P0 경로를 다시 실행한다.
+RP-06에서 inbound form/page 경계도 실행됐다. `src/presentation/forms`는 Zod
+presentation schema, controlled field state, error focus, 422 allowlist,
+pending/deduplication과 dirty navigation을 local facade로 감싼다.
+`src/presentation/templates`는 slot과 landmark만 소유하고 application, HTTP,
+query vendor import는 architecture gate가 거절한다. reference feature의
+list/detail/create/status route가 각각 Collection/Detail/Form/Status template의
+실제 consumer다.
+
이 문서의 목표 구조는 기존 기반을 폐기하는 것이 아니라 이러한
불일치를 제거하는 것이다.
@@ -1249,8 +1257,8 @@ contract와 실패 분기를 우선한다.
### P1: 기본 플랫폼 완성도
-1. error/result/validation/form kernel
-2. 배타적인 async 상태와 page template
+1. 완료: error/result/validation/form kernel
+2. 완료: 배타적인 async 상태와 page template
3. Logger와 telemetry 실제 wiring
4. design-system public API, icon wrapper와 headless interaction
5. locale/message/formatter와 pseudo-locale/RTL 경계
diff --git a/docs/architecture/routing-pages-and-patterns.md b/docs/architecture/routing-pages-and-patterns.md
index df0cf4b..114eb0b 100644
--- a/docs/architecture/routing-pages-and-patterns.md
+++ b/docs/architecture/routing-pages-and-patterns.md
@@ -12,9 +12,9 @@
- page controller와 application input use case의 연결
- 프론트엔드에서 반복 사용하는 설계 패턴
-## 2. 현재 상태와 문제
+## 2. 현재 상태와 구현 기준
-RP-04 이후 현재 구현에는 다음 장점이 있다.
+RP-04 이후 route runtime과 RP-06 page/form platform에는 다음 장점이 있다.
- route registry가 path와 access policy를 소유한다.
- contract에서 Data Router route object와 navigation을 생성한다.
@@ -22,6 +22,12 @@ RP-04 이후 현재 구현에는 다음 장점이 있다.
- 앱 셸과 보호 route, not-found surface가 있다.
- route heading focus와 비동기/render error boundary가 있다.
- redirect loop와 chunk recovery가 bounded production call graph에 연결돼 있다.
+- `src/presentation/templates`가 standard/collection/detail/form/status slot,
+ landmark와 responsive layout을 제공한다.
+- `src/presentation/forms`가 첫 오류 focus, error summary, 422 mapping,
+ duplicate submit과 dirty route blocker를 소유한다.
+- reference feature의 list/detail/create/status route가 네 template variation을
+ production composition에서 실행한다.
platform route 계약, `src/features/installed-feature-contracts.js`,
`src/features/installed-feature-runtimes.tsx`의 완전성은 TypeScript와 registry
@@ -31,9 +37,11 @@ access, title, navigation, chunk ID는
manifest의 dynamic entry는 release manifest route chunk map과 검증되며,
`ChunkRecoveryBoundary`는 일반 render error와 chunk rejection을 분리한다.
-페이지도 공통 `PageHeader` 외에는 각자 section과 class를 직접 조립한다. 목록,
-상세, 편집, 오류 페이지의 반복되는 접근성·반응형·상태 표면을 기능 팀이 다시
-구현해야 한다.
+template는 데이터를 가져오지 않는다. reference page controller가 route input과
+application input을 query/form facade에 연결하고, template에는 render할 slot과
+안전한 callback만 전달한다. 이 분리는
+`page-templates-own-layout-only` dependency rule과 forbidden import fixture가
+검증한다.
## 3. React Router mode 결정
diff --git a/docs/architecture/typescript-state-and-data-flow.md b/docs/architecture/typescript-state-and-data-flow.md
index 8696445..894662a 100644
--- a/docs/architecture/typescript-state-and-data-flow.md
+++ b/docs/architecture/typescript-state-and-data-flow.md
@@ -487,8 +487,12 @@ raw response body, stack, token, URL query, PII를 사용자 copy나 일반 log
## 8. 폼 표준
-form vendor는 React Hook Form 또는 TanStack Form 등을 평가하되 local facade 뒤에
-둔다. vendor 선택과 무관하게 다음 API를 제공한다.
+VD-04에 따라 현재 기본 엔진은 React native form event와 controlled value이며
+Zod를 local facade 뒤에서 사용한다. 동적 field array, 비동기 field validation,
+대규모 render isolation 요구가 실제로 생기면 public API를 유지한 채 React Hook
+Form 또는 TanStack Form adapter를 평가한다.
+
+현재 public API는 다음과 같다.
- `Form`
- `FormField`
@@ -500,6 +504,18 @@ form vendor는 React Hook Form 또는 TanStack Form 등을 평가하되 local fa
- `mapValidationFailureToFields`
- `useDirtyNavigationGuard`
+구현 위치:
+
+- controller와 mapping: `src/presentation/forms`
+- layout-only template: `src/presentation/templates`
+- feature form schema/command mapper:
+ `src/features/reference-feature/presentation/reference-resource-form.ts`
+- 실제 create page:
+ `src/features/reference-feature/presentation/reference-resource-form-page.tsx`
+
+`ApiFailure.validationIssues`는 HTTP 경계가 투영한 `path`와 `code`만 담는다.
+backend message와 알 수 없는 path는 field copy로 사용하지 않는다.
+
필수 동작:
1. label, description, error를 stable ID와 `aria-describedby`로 연결
diff --git a/docs/styling/design-system-platform.md b/docs/styling/design-system-platform.md
index 3326ce9..6a03aab 100644
--- a/docs/styling/design-system-platform.md
+++ b/docs/styling/design-system-platform.md
@@ -34,6 +34,10 @@
- 초기 로딩, 빈 화면, terminal error, background 상태
- `src/presentation/components/state-surfaces.jsx`
- 인증 필요, 권한 없음, 찾을 수 없음
+- `src/presentation/forms`
+ - local form facade, field/error summary, dirty navigation dialog
+- `src/presentation/templates`
+ - Standard, Collection, Detail, Form, Status page template
- `/examples/ui`, `/examples/states`
- 실행 가능한 primitive와 상태 예제
- component/E2E/axe 테스트
@@ -47,8 +51,8 @@
1. 토큰이 색상 중심이며 typography, elevation, motion, z-layer, control size,
breakpoint가 계약으로 닫혀 있지 않다.
-2. 공통 입력은 `TextField` 하나뿐이어서 일반적인 폼을 공통 규칙으로 만들 수
- 없다.
+2. RP-06 form/page foundation은 준비됐지만 TextArea, Select, Checkbox,
+ RadioGroup 같은 form primitive 확장은 RP-07에 남아 있다.
3. 앱 셸과 예제 화면에서 공용 primitive 대신 raw `button`, `select`, 링크
class를 다시 작성하는 곳이 있다.
4. `☰`, `×` 같은 문자 glyph를 직접 사용하며 아이콘 공급자 경계가 없다.
@@ -56,8 +60,8 @@
6. runtime gallery는 있지만 격리된 story, interaction story, 시각 회귀 기준선이
없다.
7. 사용자 문구가 한국어 literal로 고정되어 locale과 RTL 계약이 없다.
-8. `StandardPage`, `CollectionPage`, `DetailPage`, `FormPage` 같은 공통 페이지
- 템플릿이 없다.
+8. page template은 준비됐지만 RP-07 design-system public entry와 token/pattern
+ 계층으로 정리해야 한다.
9. 모바일 sidebar는 표시 전환은 되지만 modal drawer 수준의 focus 이동, focus
복원, 배경 비활성화 계약은 없다.
diff --git a/docs/testing/frontend-platform-testing-strategy.md b/docs/testing/frontend-platform-testing-strategy.md
index 3f5d5fe..cc0cfad 100644
--- a/docs/testing/frontend-platform-testing-strategy.md
+++ b/docs/testing/frontend-platform-testing-strategy.md
@@ -454,6 +454,16 @@ composition을 검증한다. 제거 모드는 feature source/tests와 installed
contract/runtime/adapter contribution을 제거한 복제본에서 P0 gate와 built asset
잔여 0개를 다시 검증한다.
+RP-06 form/page matrix는 `tests/component/form-foundation.test.tsx`,
+`tests/component/page-templates.test.tsx`,
+`tests/features/reference-feature/reference-page.test.tsx`와
+`tests/e2e/reference-form.spec.js`에 있다. client validation에서 command 0회와
+첫 오류 focus, Zod transform/default, pending 중 중복 제출, 승인된 422
+field/unknown field mapping, conflict 입력 보존, reset/dirty, navigation
+confirmation/focus restore, URL/storage 비노출과 320px reflow를 검증한다.
+HTTP integration test는 backend copy를 버리고 422 `path`/`code`만 전달하는지
+별도로 검사한다.
+
## 8. Form 테스트
Form test는 field primitive, form controller, application command mapping을
diff --git a/eslint.config.js b/eslint.config.js
index c2a92ef..6697937 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -190,6 +190,17 @@ export default [
]),
},
},
+ {
+ files: [`src/presentation/templates/**/*.${sourceExtensions}`],
+ rules: {
+ "no-restricted-imports": restrictedImports([
+ "**/application/**",
+ "**/adapters/**",
+ "**/bootstrap/**",
+ "@tanstack/**",
+ ]),
+ },
+ },
{
files: [`src/adapters/**/*.${sourceExtensions}`],
rules: {
@@ -272,6 +283,7 @@ export default [
"@tanstack/**",
"react",
"react-dom",
+ "**/application/**",
]),
"no-restricted-globals": [
"error",
diff --git a/package.json b/package.json
index 936e69a..64f9bf5 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"check:types:fixture:application-input": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts",
"check:types:fixture:async-overlay": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts",
"check:types:fixture:route-runtime": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts",
+ "check:types:fixture:page-action": "tsc --ignoreConfig --allowJs --checkJs --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-page-action.tsx",
"test:runtime-schema": "vitest run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests",
"test:unit": "vitest run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml",
"test:component": "vitest run tests/component --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/component.xml",
diff --git a/public/release-manifest.json b/public/release-manifest.json
index ce70cd6..553bde5 100644
--- a/public/release-manifest.json
+++ b/public/release-manifest.json
@@ -14,6 +14,9 @@
"route-examples-states": "src/presentation/examples/state-gallery-page.jsx",
"route-examples-auth": "src/presentation/examples/auth-example-page.jsx",
"route-reference-resources": "src/features/reference-feature/presentation/reference-resource-page.tsx",
+ "route-reference-resource-detail": "src/features/reference-feature/presentation/reference-resource-detail-page.tsx",
+ "route-reference-resource-form": "src/features/reference-feature/presentation/reference-resource-form-page.tsx",
+ "route-reference-resource-status": "src/features/reference-feature/presentation/reference-resource-status-page.tsx",
"route-not-found": "src/presentation/pages/not-found-page.jsx"
}
}
diff --git a/scripts/check-registries.mjs b/scripts/check-registries.mjs
index 60692da..7586c6d 100644
--- a/scripts/check-registries.mjs
+++ b/scripts/check-registries.mjs
@@ -173,6 +173,11 @@ const adHocPatterns = [
/** @param {string} directory */
async function scanDirectory(directory) {
+ try {
+ await access(directory);
+ } catch {
+ return;
+ }
const entries = await import("node:fs/promises").then(({ readdir }) =>
readdir(directory, { withFileTypes: true }),
);
diff --git a/scripts/test-sample-removal.mjs b/scripts/test-sample-removal.mjs
index 0cf7677..ee3fe8b 100644
--- a/scripts/test-sample-removal.mjs
+++ b/scripts/test-sample-removal.mjs
@@ -14,6 +14,11 @@ const fixtureRoot = path.resolve(".tmp/reference-feature-removal");
const pnpmCli = /** @type {string} */ (process.env.npm_execpath);
const featureSource = "src/features/reference-feature";
const featureTests = "tests/features/reference-feature";
+const featureOwnedPaths = [
+ featureSource,
+ featureTests,
+ "tests/e2e/reference-form.spec.js",
+];
const copyTargets = [
"src",
"tests",
@@ -112,14 +117,12 @@ for (const target of copyTargets) {
}
await symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
-await rm(path.join(fixtureRoot, featureSource), {
- recursive: true,
- force: true,
-});
-await rm(path.join(fixtureRoot, featureTests), {
- recursive: true,
- force: true,
-});
+for (const ownedPath of featureOwnedPaths) {
+ await rm(path.join(fixtureRoot, ownedPath), {
+ recursive: true,
+ force: true,
+ });
+}
await writeFile(
path.join(fixtureRoot, "src/features/installed-feature-contracts.js"),
emptyContracts,
diff --git a/src/adapters/http/client.js b/src/adapters/http/client.js
index a590c3e..d456193 100644
--- a/src/adapters/http/client.js
+++ b/src/adapters/http/client.js
@@ -4,6 +4,7 @@ import {
createFailure as failure,
kindForStatus as statusKind,
normalizeUnknownFailure,
+ safeValidationIssues,
} from "../../contracts/errors.js";
import { mapOperationPayload } from "./resource-mapper.js";
import { retryDelay, shouldRetry } from "./retry-policy.js";
@@ -415,6 +416,10 @@ async function parseResponse(
const kind = statusKind(response.status);
const retryAfter = response.headers.get("retry-after");
+ const backendError =
+ envelopeRecord.error && typeof envelopeRecord.error === "object"
+ ? /** @type {Record} */ (envelopeRecord.error)
+ : {};
return {
ok: false,
error: failure(kind, operation.operationId, attempt, {
@@ -426,6 +431,10 @@ async function parseResponse(
response.status === 429 && retryAfter
? parseRetryAfterHeader(retryAfter)
: undefined,
+ validationIssues:
+ response.status === 422
+ ? safeValidationIssues(backendError.details)
+ : undefined,
}),
};
}
diff --git a/src/contracts/errors.js b/src/contracts/errors.js
index 40d924f..fb7429d 100644
--- a/src/contracts/errors.js
+++ b/src/contracts/errors.js
@@ -195,6 +195,7 @@ export const ERROR_REGISTRY = Object.freeze({
* requestId?: string,
* traceId?: string,
* retryAfterMs?: number,
+ * validationIssues?: readonly Readonly<{path: string, code: string}>[],
* userMessageKey: string,
* action: ErrorAction,
* causeClass?: string
@@ -211,6 +212,7 @@ export const ERROR_REGISTRY = Object.freeze({
* requestId?: string,
* traceId?: string,
* retryAfterMs?: number,
+ * validationIssues?: readonly Readonly<{path: string, code: string}>[],
* causeClass?: string
* }} [details]
* @returns {ApiFailure}
@@ -236,6 +238,24 @@ export function createFailure(kind, operationId, attempt, details = {}) {
...(typeof details.retryAfterMs === "number"
? { retryAfterMs: details.retryAfterMs }
: {}),
+ ...(Array.isArray(details.validationIssues)
+ ? {
+ validationIssues: Object.freeze(
+ details.validationIssues
+ .filter(
+ (issue) =>
+ issue &&
+ typeof issue === "object" &&
+ typeof issue.path === "string" &&
+ typeof issue.code === "string",
+ )
+ .slice(0, 50)
+ .map((issue) =>
+ Object.freeze({ path: issue.path, code: issue.code }),
+ ),
+ ),
+ }
+ : {}),
...(typeof details.causeClass === "string"
? { causeClass: details.causeClass }
: {}),
@@ -244,6 +264,44 @@ export function createFailure(kind, operationId, attempt, details = {}) {
});
}
+/**
+ * Projects an untrusted 422 details payload into the only validation metadata
+ * allowed to cross the HTTP boundary. Backend copy and additional values are
+ * deliberately discarded.
+ *
+ * @param {unknown} value
+ * @returns {readonly Readonly<{path: string, code: string}>[]}
+ */
+export function safeValidationIssues(value) {
+ if (!value || typeof value !== "object") return Object.freeze([]);
+ const candidate =
+ /** @type {{issues?: unknown, fieldErrors?: unknown}} */ (value);
+ const issues = Array.isArray(candidate.issues)
+ ? candidate.issues
+ : Array.isArray(candidate.fieldErrors)
+ ? candidate.fieldErrors
+ : [];
+ return Object.freeze(
+ issues
+ .filter(
+ (issue) =>
+ issue &&
+ typeof issue === "object" &&
+ typeof issue.path === "string" &&
+ typeof issue.code === "string" &&
+ issue.path.length <= 120 &&
+ issue.code.length <= 80,
+ )
+ .slice(0, 50)
+ .map((issue) =>
+ Object.freeze({
+ path: issue.path,
+ code: issue.code,
+ }),
+ ),
+ );
+}
+
/** @param {number} status */
export function kindForStatus(status) {
if (status === 401) return "AUTH_REQUIRED";
diff --git a/src/features/reference-feature/README.md b/src/features/reference-feature/README.md
index ef1d454..80be3c8 100644
--- a/src/features/reference-feature/README.md
+++ b/src/features/reference-feature/README.md
@@ -6,11 +6,11 @@ reference implementation이다.
## 소유 경계
- `domain`: 외부 DTO와 React를 모르는 불변 model
-- `application`: UI가 호출하는 list/create input과 gateway 계약
+- `application`: UI가 호출하는 list/get/create input과 gateway 계약
- `adapters`: HTTP executor를 gateway로 투영하는 outbound adapter
- `contracts`: route/API/query contribution, Zod DTO와 request schema, mapper
-- `presentation`: route input을 query/mutation controller로 연결하는 inbound
- adapter와 page
+- `presentation`: route input을 query/form controller로 연결하는 inbound
+ adapter, 독립 form schema/command mapper와 list/detail/form/status page
generic application은 `features.get(featureId)` catalog만 제공한다. feature hook이
자신의 input shape를 확인하며 page는 HTTP client, storage, auth owner, output
@@ -33,7 +33,7 @@ corepack pnpm test:sample-removal
```
첫 명령은 URL filter와 query key/HTTP request의 동일성, schema/mapper, 모든
-query/mutation 상태와 production composition을 검증한다. 두 번째 명령은 임시
+query/mutation/form 상태와 production composition을 검증한다. 두 번째 명령은 임시
복제본에서 이 source/test 디렉터리를 제거하고 installed catalog를 빈 목록으로
재생성한 뒤 typecheck, architecture, registry, unit/integration, home smoke,
-production build와 fixture ID 잔여 0개를 검사한다.
+production build와 source/built fixture ID 잔여 0개를 검사한다.
diff --git a/src/features/reference-feature/adapters/reference-http-gateway.ts b/src/features/reference-feature/adapters/reference-http-gateway.ts
index 5497a4d..6063c94 100644
--- a/src/features/reference-feature/adapters/reference-http-gateway.ts
+++ b/src/features/reference-feature/adapters/reference-http-gateway.ts
@@ -14,6 +14,7 @@ type HttpExecutor = Readonly<{
request: Readonly<{
operationId: string;
routeId: string;
+ pathParams?: Record;
searchParams?: unknown;
body?: unknown;
signal?: AbortSignal;
@@ -42,7 +43,7 @@ export function createReferenceHttpGateway(
}
: result;
},
- async create(command: Readonly<{ name: string }>) {
+ async create(command: Readonly<{ name: string; note?: string }>) {
const result = await http.execute({
operationId: "CREATE_REFERENCE_RESOURCE",
routeId: "REFERENCE_RESOURCE_LIST",
@@ -52,5 +53,19 @@ export function createReferenceHttpGateway(
? { ok: true as const, value: result.value as ReferenceResource }
: result;
},
+ async get(
+ resourceId: string,
+ context?: Readonly<{ signal?: AbortSignal }>,
+ ) {
+ const result = await http.execute({
+ operationId: "GET_REFERENCE_RESOURCE",
+ routeId: "REFERENCE_RESOURCE_DETAIL",
+ pathParams: { resourceId },
+ signal: context?.signal,
+ });
+ return result.ok
+ ? { ok: true as const, value: result.value as ReferenceResource }
+ : result;
+ },
});
}
diff --git a/src/features/reference-feature/application/reference-feature-api.ts b/src/features/reference-feature/application/reference-feature-api.ts
index c8d0e40..0eade92 100644
--- a/src/features/reference-feature/application/reference-feature-api.ts
+++ b/src/features/reference-feature/application/reference-feature-api.ts
@@ -21,7 +21,11 @@ export type ReferenceFeatureInput = Readonly<{
context?: Readonly<{ signal?: AbortSignal }>,
): Promise>;
createResource(
- command: Readonly<{ name: string }>,
+ command: Readonly<{ name: string; note?: string }>,
+ ): Promise>;
+ getResource(
+ resourceId: string,
+ context?: Readonly<{ signal?: AbortSignal }>,
): Promise>;
}>;
@@ -31,7 +35,11 @@ export type ReferenceGateway = Readonly<{
context?: Readonly<{ signal?: AbortSignal }>,
): Promise>;
create(
- command: Readonly<{ name: string }>,
+ command: Readonly<{ name: string; note?: string }>,
+ ): Promise>;
+ get(
+ resourceId: string,
+ context?: Readonly<{ signal?: AbortSignal }>,
): Promise>;
}>;
@@ -54,5 +62,11 @@ export function createReferenceFeatureInput(
? { ok: true as const, value: toReferenceView(result.value) }
: result;
},
+ async getResource(resourceId, context) {
+ const result = await gateway.get(resourceId, context);
+ return result.ok
+ ? { ok: true as const, value: toReferenceView(result.value) }
+ : result;
+ },
});
}
diff --git a/src/features/reference-feature/contracts/reference-feature-contract.js b/src/features/reference-feature/contracts/reference-feature-contract.js
index b34fe03..86f4d07 100644
--- a/src/features/reference-feature/contracts/reference-feature-contract.js
+++ b/src/features/reference-feature/contracts/reference-feature-contract.js
@@ -7,6 +7,9 @@ export const referenceQueryKeys = Object.freeze({
all: () => REFERENCE_NAMESPACE,
list: (filters = {}) =>
Object.freeze([...REFERENCE_NAMESPACE, "list", canonicalize(filters)]),
+ /** @param {string} resourceId */
+ detail: (resourceId) =>
+ Object.freeze([...REFERENCE_NAMESPACE, "detail", String(resourceId)]),
});
export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
@@ -25,6 +28,45 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
navigationLabel: "Reference feature",
navigationOrder: 50,
}),
+ REFERENCE_RESOURCE_DETAIL: Object.freeze({
+ routeId: "REFERENCE_RESOURCE_DETAIL",
+ path: "/examples/reference-resources/:resourceId",
+ paramsSchema: "ReferenceResourceParams",
+ searchSchema: null,
+ access: "integration-defined",
+ loadingSurface: "reference-resource-detail",
+ errorSurface: "feature-boundary",
+ chunkId: "route-reference-resource-detail",
+ title: "Reference detail",
+ navigationLabel: null,
+ navigationOrder: null,
+ }),
+ REFERENCE_RESOURCE_FORM: Object.freeze({
+ routeId: "REFERENCE_RESOURCE_FORM",
+ path: "/examples/reference-resources/new",
+ paramsSchema: null,
+ searchSchema: null,
+ access: "integration-defined",
+ loadingSurface: "reference-resource-form",
+ errorSurface: "feature-boundary",
+ chunkId: "route-reference-resource-form",
+ title: "Reference form",
+ navigationLabel: null,
+ navigationOrder: null,
+ }),
+ REFERENCE_RESOURCE_STATUS: Object.freeze({
+ routeId: "REFERENCE_RESOURCE_STATUS",
+ path: "/examples/reference-resources/status",
+ paramsSchema: null,
+ searchSchema: null,
+ access: "integration-defined",
+ loadingSurface: "reference-resource-status",
+ errorSurface: "feature-boundary",
+ chunkId: "route-reference-resource-status",
+ title: "Reference status",
+ navigationLabel: null,
+ navigationOrder: null,
+ }),
}),
routeRuntimeContracts: Object.freeze({
REFERENCE_RESOURCE_LIST: Object.freeze({
@@ -33,6 +75,24 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
paramsCodec: "none",
searchCodec: "ReferenceResourceListQuery",
}),
+ REFERENCE_RESOURCE_DETAIL: Object.freeze({
+ routeId: "REFERENCE_RESOURCE_DETAIL",
+ moduleId: "reference-resource-detail-page",
+ paramsCodec: "ReferenceResourceParams",
+ searchCodec: "none",
+ }),
+ REFERENCE_RESOURCE_FORM: Object.freeze({
+ routeId: "REFERENCE_RESOURCE_FORM",
+ moduleId: "reference-resource-form-page",
+ paramsCodec: "none",
+ searchCodec: "none",
+ }),
+ REFERENCE_RESOURCE_STATUS: Object.freeze({
+ routeId: "REFERENCE_RESOURCE_STATUS",
+ moduleId: "reference-resource-status-page",
+ paramsCodec: "none",
+ searchCodec: "none",
+ }),
}),
apiOperations: Object.freeze({
LIST_REFERENCE_RESOURCES: Object.freeze({
@@ -61,6 +121,19 @@ export const REFERENCE_FEATURE_CONTRACT = Object.freeze({
responseSchema: "ReferenceResourcePayload",
owner: "feature-frontend-reference-feature-vertical-slice",
}),
+ GET_REFERENCE_RESOURCE: Object.freeze({
+ method: "GET",
+ path: "/api/reference-resources/{resourceId}",
+ operationId: "GET_REFERENCE_RESOURCE",
+ auth: "external-session",
+ timeoutMs: null,
+ idempotency: "safe",
+ retry: "runtime",
+ requestSource: "none",
+ requestSchema: "NoRequest",
+ responseSchema: "ReferenceResourcePayload",
+ owner: "feature-frontend-form-page-platform",
+ }),
}),
queryRegistry: Object.freeze({
REFERENCE_RESOURCE: Object.freeze({
diff --git a/src/features/reference-feature/contracts/reference-mapper.ts b/src/features/reference-feature/contracts/reference-mapper.ts
index 634732b..06b39a6 100644
--- a/src/features/reference-feature/contracts/reference-mapper.ts
+++ b/src/features/reference-feature/contracts/reference-mapper.ts
@@ -33,7 +33,10 @@ export function mapReferenceOperation(
if (!Array.isArray(payload)) throw new TypeError("Expected a reference list");
return payload.map(mapReferenceDto);
}
- if (operationId === "CREATE_REFERENCE_RESOURCE") {
+ if (
+ operationId === "CREATE_REFERENCE_RESOURCE" ||
+ operationId === "GET_REFERENCE_RESOURCE"
+ ) {
return mapReferenceDto(payload);
}
throw new TypeError(`No reference mapper registered for ${operationId}`);
diff --git a/src/features/reference-feature/contracts/reference-schemas.ts b/src/features/reference-feature/contracts/reference-schemas.ts
index 08d8066..0c6031b 100644
--- a/src/features/reference-feature/contracts/reference-schemas.ts
+++ b/src/features/reference-feature/contracts/reference-schemas.ts
@@ -18,6 +18,12 @@ export const referenceResourceListQuerySchema = z
})
.strict();
+export const referenceResourceParamsSchema = z
+ .object({
+ resourceId: z.string().trim().min(1).max(120),
+ })
+ .strict();
+
const referenceResourceDtoSchema = z
.object({
id: z.string().min(1),
@@ -36,6 +42,7 @@ const requestSchemas = {
CreateReferenceResourceCommand: z
.object({
name: z.string().trim().min(1).max(120),
+ note: z.string().trim().max(500).optional(),
})
.strict(),
} satisfies Record;
diff --git a/src/features/reference-feature/presentation/reference-feature-runtime.tsx b/src/features/reference-feature/presentation/reference-feature-runtime.tsx
index cd16f99..68da388 100644
--- a/src/features/reference-feature/presentation/reference-feature-runtime.tsx
+++ b/src/features/reference-feature/presentation/reference-feature-runtime.tsx
@@ -1,9 +1,13 @@
import { lazy } from "react";
-import { referenceResourceListQuerySchema } from "../contracts/reference-schemas.js";
+import {
+ referenceResourceListQuerySchema,
+ referenceResourceParamsSchema,
+} from "../contracts/reference-schemas.js";
export const REFERENCE_FEATURE_ROUTE_CODECS = {
ReferenceResourceListQuery: referenceResourceListQuerySchema,
+ ReferenceResourceParams: referenceResourceParamsSchema,
} as const;
export const REFERENCE_FEATURE_ROUTE_RUNTIME = {
@@ -11,4 +15,16 @@ export const REFERENCE_FEATURE_ROUTE_RUNTIME = {
moduleId: "reference-resource-page",
Component: lazy(() => import("./reference-resource-page.js")),
}),
+ REFERENCE_RESOURCE_DETAIL: Object.freeze({
+ moduleId: "reference-resource-detail-page",
+ Component: lazy(() => import("./reference-resource-detail-page.js")),
+ }),
+ REFERENCE_RESOURCE_FORM: Object.freeze({
+ moduleId: "reference-resource-form-page",
+ Component: lazy(() => import("./reference-resource-form-page.js")),
+ }),
+ REFERENCE_RESOURCE_STATUS: Object.freeze({
+ moduleId: "reference-resource-status-page",
+ Component: lazy(() => import("./reference-resource-status-page.js")),
+ }),
} as const;
diff --git a/src/features/reference-feature/presentation/reference-resource-detail-page.tsx b/src/features/reference-feature/presentation/reference-resource-detail-page.tsx
new file mode 100644
index 0000000..cedacca
--- /dev/null
+++ b/src/features/reference-feature/presentation/reference-resource-detail-page.tsx
@@ -0,0 +1,47 @@
+import { Link } from "react-router-dom";
+
+import { AsyncSurface } from "../../../presentation/components/async-surface.jsx";
+import { DetailPage } from "../../../presentation/templates/index.js";
+import { useRouteInput } from "../../../presentation/routes/app-router.js";
+import { useReferenceDetail } from "./use-reference-feature.js";
+
+export default function ReferenceResourceDetailPage() {
+ const route = useRouteInput();
+ const resourceId = String(route.params.resourceId);
+ const { query } = useReferenceDetail(resourceId);
+ const resource = query.data;
+
+ return (
+ Reference resources
+ }
+ heading={{
+ eyebrow: "DetailPage",
+ title: resource?.title ?? "Reference detail",
+ description: "route param과 detail query의 reset 경계를 확인합니다.",
+ }}
+ metadata={
+ resource ? (
+
+ - Resource ID
+ - {resource.resourceId}
+ - Created
+ - {resource.createdAtLabel ?? "표시 정보 없음"}
+
+ ) : (
+ 요약 정보를 준비하고 있습니다.
+ )
+ }
+ feedback={
+
+ {resource ? (
+ 이 영역에는 제품별 상세 section을 조립할 수 있습니다.
+ ) : null}
+
+ }
+ aside={상세 페이지의 관련 정보 slot입니다.
}
+ />
+ );
+}
diff --git a/src/features/reference-feature/presentation/reference-resource-form-page.tsx b/src/features/reference-feature/presentation/reference-resource-form-page.tsx
new file mode 100644
index 0000000..8e90782
--- /dev/null
+++ b/src/features/reference-feature/presentation/reference-resource-form-page.tsx
@@ -0,0 +1,131 @@
+import { useCallback } from "react";
+import { useNavigate } from "react-router-dom";
+
+import { Button } from "../../../presentation/components/ui/button.jsx";
+import {
+ DirtyNavigationDialog,
+ ErrorSummary,
+ Form,
+ FormActions,
+ FormField,
+ useAppForm,
+ useDirtyNavigationGuard,
+} from "../../../presentation/forms/index.js";
+import { FormPage } from "../../../presentation/templates/index.js";
+import {
+ REFERENCE_FORM_DEFAULTS,
+ referenceResourceFormSchema,
+ toCreateReferenceCommand,
+ type ReferenceResourceFormValues,
+} from "./reference-resource-form.js";
+import { useReferenceCreate } from "./use-reference-feature.js";
+
+const FIELD_LABELS = Object.freeze({
+ name: "새 항목 이름",
+ note: "설명",
+}) satisfies Record;
+
+export default function ReferenceResourceFormPage() {
+ const navigate = useNavigate();
+ const mutation = useReferenceCreate();
+ const submit = useCallback(
+ (command: ReturnType) =>
+ mutation.submit(command),
+ [mutation],
+ );
+ const form = useAppForm({
+ schema: referenceResourceFormSchema,
+ defaultValues: REFERENCE_FORM_DEFAULTS,
+ allowedServerFields: ["name", "note"],
+ mapToCommand: toCreateReferenceCommand,
+ submit,
+ });
+ const guard = useDirtyNavigationGuard(form.dirty && !form.pending);
+
+ return (
+
+ );
+}
diff --git a/src/features/reference-feature/presentation/reference-resource-form.ts b/src/features/reference-feature/presentation/reference-resource-form.ts
new file mode 100644
index 0000000..059c596
--- /dev/null
+++ b/src/features/reference-feature/presentation/reference-resource-form.ts
@@ -0,0 +1,29 @@
+import { z } from "zod";
+
+export const referenceResourceFormSchema = z
+ .object({
+ name: z
+ .string()
+ .trim()
+ .min(2, "이름은 두 글자 이상이어야 합니다.")
+ .max(120),
+ note: z.string().trim().max(500).default(""),
+ })
+ .strict();
+
+export type ReferenceResourceFormValues = z.infer<
+ typeof referenceResourceFormSchema
+>;
+
+export const REFERENCE_FORM_DEFAULTS: ReferenceResourceFormValues =
+ Object.freeze({
+ name: "",
+ note: "",
+ });
+
+export function toCreateReferenceCommand(values: ReferenceResourceFormValues) {
+ return Object.freeze({
+ name: values.name,
+ ...(values.note ? { note: values.note } : {}),
+ });
+}
diff --git a/src/features/reference-feature/presentation/reference-resource-page.tsx b/src/features/reference-feature/presentation/reference-resource-page.tsx
index c65a4f7..f396df6 100644
--- a/src/features/reference-feature/presentation/reference-resource-page.tsx
+++ b/src/features/reference-feature/presentation/reference-resource-page.tsx
@@ -1,60 +1,53 @@
-import { useState, type FormEvent } from "react";
+import { Link, useNavigate } from "react-router-dom";
import { AsyncSurface } from "../../../presentation/components/async-surface.jsx";
import { Button } from "../../../presentation/components/ui/button.jsx";
-import { PageHeader } from "../../../presentation/components/page-header.jsx";
+import { CollectionPage } from "../../../presentation/templates/index.js";
import { useReferenceFeature } from "./use-reference-feature.js";
export default function ReferenceResourcePage() {
- const { filters, query, mutation } = useReferenceFeature();
- const [name, setName] = useState("");
-
- async function submit(event: FormEvent) {
- event.preventDefault();
- const result = await mutation.submit({ name });
- if (result.ok) setName("");
- }
+ const navigate = useNavigate();
+ const { filters, query } = useReferenceFeature();
return (
-
-
-
- limit {filters.limit}
- {filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""}
-
+ navigate("/examples/reference-resources/new"),
+ },
+ ]}
+ activeFilters={
+
+ limit {filters.limit}
+ {filters.tags?.length ? ` · tags ${filters.tags.join(", ")}` : ""}
+
+ }
+ toolbar={}
+ resultCount={
+ query.data ? `총 ${query.data.length}개 항목` : "결과 확인 중"
+ }
+ >
{(query.data ?? []).map((resource) => (
- -
- {resource.title}
+
-
+
+ {resource.title}
+
))}
-
-
-
-
+
);
}
diff --git a/src/features/reference-feature/presentation/reference-resource-status-page.tsx b/src/features/reference-feature/presentation/reference-resource-status-page.tsx
new file mode 100644
index 0000000..4f748d6
--- /dev/null
+++ b/src/features/reference-feature/presentation/reference-resource-status-page.tsx
@@ -0,0 +1,25 @@
+import { useNavigate } from "react-router-dom";
+
+import { StatusPage } from "../../../presentation/templates/index.js";
+
+export default function ReferenceResourceStatusPage() {
+ const navigate = useNavigate();
+
+ return (
+ navigate("/examples/reference-resources"),
+ }}
+ supportReference="REFERENCE-STATUS-DEMO"
+ />
+ );
+}
diff --git a/src/features/reference-feature/presentation/use-reference-feature.ts b/src/features/reference-feature/presentation/use-reference-feature.ts
index 8a4def4..f051ae6 100644
--- a/src/features/reference-feature/presentation/use-reference-feature.ts
+++ b/src/features/reference-feature/presentation/use-reference-feature.ts
@@ -14,19 +14,38 @@ import type {
ReferenceListFilters,
} from "../application/reference-feature-api.js";
-function useReferenceFeatureInput(): ReferenceFeatureInput {
+export function useReferenceFeatureInput(): ReferenceFeatureInput {
const candidate = useApplication().features.get(REFERENCE_FEATURE_ID);
if (
!candidate ||
typeof candidate !== "object" ||
typeof (candidate as ReferenceFeatureInput).listResources !== "function" ||
- typeof (candidate as ReferenceFeatureInput).createResource !== "function"
+ typeof (candidate as ReferenceFeatureInput).createResource !== "function" ||
+ typeof (candidate as ReferenceFeatureInput).getResource !== "function"
) {
throw new Error("Reference feature application input is invalid");
}
return candidate as ReferenceFeatureInput;
}
+export function useReferenceDetail(resourceId: string) {
+ const input = useReferenceFeatureInput();
+ const query = useApplicationQuery({
+ queryKey: referenceQueryKeys.detail(resourceId),
+ execute: ({ signal }) => input.getResource(resourceId, { signal }),
+ });
+ return Object.freeze({ query });
+}
+
+export function useReferenceCreate() {
+ const input = useReferenceFeatureInput();
+ return useApplicationMutation({
+ execute: input.createResource,
+ invalidate: [referenceQueryKeys.all()],
+ currentData: true,
+ });
+}
+
export function useReferenceFeature() {
const input = useReferenceFeatureInput();
const routeInput = useRouteInput();
diff --git a/src/presentation/forms/form-components.tsx b/src/presentation/forms/form-components.tsx
new file mode 100644
index 0000000..0bbfa7c
--- /dev/null
+++ b/src/presentation/forms/form-components.tsx
@@ -0,0 +1,90 @@
+import { useId, type FormHTMLAttributes, type ReactNode } from "react";
+
+import { TextField } from "../components/ui/text-field.jsx";
+import type {
+ FieldErrors,
+ FieldName,
+ FormValues,
+} from "./form-contracts.js";
+
+export function Form(
+ props: FormHTMLAttributes & Readonly<{ pending?: boolean }>,
+) {
+ const { pending = false, children, ...formProps } = props;
+ return (
+
+ );
+}
+
+export function FormField(
+ props: React.ComponentProps,
+) {
+ return ;
+}
+
+export function ErrorSummary(props: Readonly<{
+ fieldErrors: FieldErrors;
+ formErrors?: readonly string[];
+ fieldLabels: Readonly, string>>;
+ fieldId(name: FieldName): string;
+ onFocusField?(name: FieldName): void;
+}>) {
+ const {
+ fieldErrors,
+ formErrors = [],
+ fieldLabels,
+ fieldId,
+ onFocusField,
+ } = props;
+ const headingId = useId();
+ const entries = Object.entries(fieldErrors) as [
+ FieldName,
+ string,
+ ][];
+ if (entries.length === 0 && formErrors.length === 0) return null;
+ return (
+
+ 입력 내용을 확인해 주세요.
+ {entries.length > 0 ? (
+
+ ) : null}
+ {formErrors.map((message) => (
+ {message}
+ ))}
+
+ );
+}
+
+export function FormActions(props: Readonly<{
+ children: ReactNode;
+ sticky?: boolean;
+}>) {
+ return (
+
+ {props.children}
+
+ );
+}
diff --git a/src/presentation/forms/form-contracts.ts b/src/presentation/forms/form-contracts.ts
new file mode 100644
index 0000000..c1f409e
--- /dev/null
+++ b/src/presentation/forms/form-contracts.ts
@@ -0,0 +1,69 @@
+import type { ApiFailure } from "../../contracts/errors.js";
+
+export type FormValues = Readonly>;
+export type FieldName = Extract;
+export type FieldErrors = Readonly<
+ Partial, string>>
+>;
+
+export type FormResult =
+ | Readonly<{ ok: true; value: Value }>
+ | Readonly<{ ok: false; error: ApiFailure }>;
+
+export type FormResultState =
+ | "idle"
+ | "success"
+ | "validation-error"
+ | "conflict"
+ | "unavailable";
+
+export type MappedValidationFailure = Readonly<{
+ fieldErrors: FieldErrors;
+ formErrors: readonly string[];
+}>;
+
+const VALIDATION_COPY = Object.freeze({
+ REQUIRED: "필수 입력값입니다.",
+ too_small: "입력값이 너무 짧습니다.",
+ too_big: "입력값이 너무 깁니다.",
+ invalid_type: "입력 형식을 확인해 주세요.",
+ invalid_format: "입력 형식을 확인해 주세요.",
+ invalid_value: "허용된 값을 선택해 주세요.",
+});
+
+export function validationMessage(code: string): string {
+ return (
+ VALIDATION_COPY[code as keyof typeof VALIDATION_COPY] ??
+ "입력값을 확인해 주세요."
+ );
+}
+
+export function mapValidationFailureToFields(
+ failure: ApiFailure,
+ allowedFields: readonly FieldName[],
+): MappedValidationFailure {
+ if (failure.kind !== "VALIDATION_REJECTED") {
+ return Object.freeze({ fieldErrors: Object.freeze({}), formErrors: [] });
+ }
+ const allowed = new Set(allowedFields);
+ const fieldErrors: Partial, string>> = {};
+ const formErrors: string[] = [];
+ const issues = failure.validationIssues ?? [];
+
+ if (issues.length === 0) {
+ formErrors.push("입력값을 다시 확인해 주세요.");
+ }
+ for (const issue of issues) {
+ const field = issue.path.split(".").at(0) ?? "";
+ if (allowed.has(field)) {
+ const name = field as FieldName;
+ fieldErrors[name] ??= validationMessage(issue.code);
+ } else {
+ formErrors.push("서버가 확인하지 못한 입력 항목이 있습니다.");
+ }
+ }
+ return Object.freeze({
+ fieldErrors: Object.freeze(fieldErrors),
+ formErrors: Object.freeze([...new Set(formErrors)]),
+ });
+}
diff --git a/src/presentation/forms/index.ts b/src/presentation/forms/index.ts
new file mode 100644
index 0000000..7ea1e7f
--- /dev/null
+++ b/src/presentation/forms/index.ts
@@ -0,0 +1,4 @@
+export * from "./form-components.js";
+export * from "./form-contracts.js";
+export * from "./use-app-form.js";
+export * from "./use-dirty-navigation-guard.js";
diff --git a/src/presentation/forms/use-app-form.ts b/src/presentation/forms/use-app-form.ts
new file mode 100644
index 0000000..f17b4e9
--- /dev/null
+++ b/src/presentation/forms/use-app-form.ts
@@ -0,0 +1,244 @@
+import {
+ useCallback,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+ type ChangeEvent,
+ type FormEvent,
+} from "react";
+import type { ZodType, ZodIssue } from "zod";
+
+import {
+ mapValidationFailureToFields,
+ validationMessage,
+ type FieldErrors,
+ type FieldName,
+ type FormResult,
+ type FormResultState,
+ type FormValues,
+} from "./form-contracts.js";
+
+type AppFormOptions<
+ Values extends FormValues,
+ Command,
+ Output,
+> = Readonly<{
+ schema: ZodType;
+ defaultValues: Values;
+ allowedServerFields: readonly FieldName[];
+ mapToCommand(values: Values): Command;
+ submit(command: Command): Promise>;
+ resetOnSuccess?: boolean;
+}>;
+
+export function useAppForm<
+ Values extends FormValues,
+ Command,
+ Output,
+>(options: AppFormOptions) {
+ const {
+ schema,
+ defaultValues,
+ allowedServerFields,
+ mapToCommand,
+ submit,
+ resetOnSuccess = true,
+ } = options;
+ const generatedId = useId().replaceAll(":", "");
+ const formId = `app-form-${generatedId}`;
+ const [values, setValues] = useState(defaultValues);
+ const [initialValues, setInitialValues] = useState(defaultValues);
+ const [touched, setTouched] = useState>>(
+ () => new Set(),
+ );
+ const [fieldErrors, setFieldErrors] = useState>(
+ () => ({} as FieldErrors),
+ );
+ const [formErrors, setFormErrors] = useState([]);
+ const [pending, setPending] = useState(false);
+ const [result, setResult] = useState("idle");
+ const pendingRef = useRef> | null>(null);
+
+ const dirty = useMemo(
+ () => JSON.stringify(values) !== JSON.stringify(initialValues),
+ [initialValues, values],
+ );
+
+ const fieldId = useCallback(
+ (name: FieldName) => `${formId}-${name}`,
+ [formId],
+ );
+
+ const focusField = useCallback(
+ (name: FieldName) => {
+ const field = document.getElementById(fieldId(name));
+ if (field instanceof HTMLElement) field.focus();
+ },
+ [fieldId],
+ );
+
+ const focusFirstError = useCallback(
+ (errors: FieldErrors) => {
+ const first = allowedServerFields.find((name) => Boolean(errors[name]));
+ if (first) focusField(first);
+ },
+ [allowedServerFields, focusField],
+ );
+
+ const setValue = useCallback(
+ (name: FieldName, value: Values[FieldName]) => {
+ setValues((current) => ({ ...current, [name]: value }) as Values);
+ setFieldErrors((current) => {
+ if (!current[name]) return current;
+ const next = { ...current };
+ delete next[name];
+ return next;
+ });
+ setFormErrors([]);
+ setResult("idle");
+ },
+ [],
+ );
+
+ const field = useCallback(
+ (name: FieldName) => ({
+ id: fieldId(name),
+ name,
+ value: String(values[name] ?? ""),
+ onChange(event: ChangeEvent) {
+ setValue(name, event.currentTarget.value as Values[FieldName]);
+ },
+ onBlur() {
+ setTouched((current) => new Set(current).add(name));
+ },
+ error: fieldErrors[name],
+ "aria-invalid": fieldErrors[name] ? ("true" as const) : undefined,
+ }),
+ [fieldErrors, fieldId, setValue, values],
+ );
+
+ const reset = useCallback(
+ (nextValues: Values = defaultValues) => {
+ setValues(nextValues);
+ setInitialValues(nextValues);
+ setTouched(new Set());
+ setFieldErrors({} as FieldErrors);
+ setFormErrors([]);
+ setResult("idle");
+ },
+ [defaultValues],
+ );
+
+ const submitForm = useCallback(
+ async (event?: FormEvent): Promise | null> => {
+ event?.preventDefault();
+ if (pendingRef.current) return pendingRef.current;
+ setFieldErrors({} as FieldErrors);
+ setFormErrors([]);
+
+ const parsed = await schema.safeParseAsync(values);
+ if (!parsed.success) {
+ const errors = issuesToFieldErrors(
+ parsed.error.issues,
+ allowedServerFields,
+ );
+ setFieldErrors(errors);
+ setFormErrors(
+ parsed.error.issues.some(
+ (issue) => !allowedServerFields.includes(issue.path[0] as FieldName),
+ )
+ ? ["입력 구성을 다시 확인해 주세요."]
+ : [],
+ );
+ setTouched(new Set(allowedServerFields));
+ setResult("validation-error");
+ focusFirstError(errors);
+ return null;
+ }
+
+ const command = mapToCommand(parsed.data);
+ setPending(true);
+ const execution = submit(command);
+ pendingRef.current = execution;
+ try {
+ const outcome = await execution;
+ if (outcome.ok) {
+ setResult("success");
+ if (resetOnSuccess) {
+ setValues(defaultValues);
+ setInitialValues(defaultValues);
+ setTouched(new Set());
+ } else {
+ setInitialValues(parsed.data);
+ }
+ return outcome;
+ }
+ if (outcome.error.kind === "VALIDATION_REJECTED") {
+ const mapped = mapValidationFailureToFields(
+ outcome.error,
+ allowedServerFields,
+ );
+ setFieldErrors(mapped.fieldErrors);
+ setFormErrors(mapped.formErrors);
+ setResult("validation-error");
+ focusFirstError(mapped.fieldErrors);
+ } else if (outcome.error.kind === "CONFLICT") {
+ setFormErrors([
+ "다른 변경과 충돌했습니다. 입력은 유지되었으니 최신 상태를 확인해 주세요.",
+ ]);
+ setResult("conflict");
+ } else {
+ setFormErrors(["저장하지 못했습니다. 잠시 후 다시 시도해 주세요."]);
+ setResult("unavailable");
+ }
+ return outcome;
+ } finally {
+ pendingRef.current = null;
+ setPending(false);
+ }
+ },
+ [
+ allowedServerFields,
+ defaultValues,
+ focusFirstError,
+ mapToCommand,
+ resetOnSuccess,
+ schema,
+ submit,
+ values,
+ ],
+ );
+
+ return Object.freeze({
+ formId,
+ values,
+ dirty,
+ touched,
+ fieldErrors,
+ formErrors,
+ pending,
+ result,
+ field,
+ fieldId,
+ focusField,
+ setValue,
+ submitForm,
+ reset,
+ });
+}
+
+function issuesToFieldErrors(
+ issues: readonly ZodIssue[],
+ allowedFields: readonly FieldName[],
+): FieldErrors {
+ const allowed = new Set(allowedFields);
+ const errors: Partial, string>> = {};
+ for (const issue of issues) {
+ const field = issue.path[0];
+ if (!allowed.has(field)) continue;
+ const name = field as FieldName;
+ errors[name] ??= validationMessage(issue.code);
+ }
+ return Object.freeze(errors);
+}
diff --git a/src/presentation/forms/use-dirty-navigation-guard.tsx b/src/presentation/forms/use-dirty-navigation-guard.tsx
new file mode 100644
index 0000000..f0fb63f
--- /dev/null
+++ b/src/presentation/forms/use-dirty-navigation-guard.tsx
@@ -0,0 +1,54 @@
+import { useCallback } from "react";
+import { useBeforeUnload, useBlocker } from "react-router-dom";
+
+import { Button } from "../components/ui/button.jsx";
+import { Dialog } from "../components/ui/dialog.jsx";
+
+export function useDirtyNavigationGuard(when: boolean) {
+ const blocker = useBlocker(when);
+
+ useBeforeUnload(
+ useCallback(
+ (event) => {
+ if (!when) return;
+ event.preventDefault();
+ event.returnValue = "";
+ },
+ [when],
+ ),
+ { capture: true },
+ );
+
+ return Object.freeze({
+ blocked: blocker.state === "blocked",
+ stay() {
+ blocker.reset?.();
+ },
+ leave() {
+ blocker.proceed?.();
+ },
+ });
+}
+
+export function DirtyNavigationDialog(props: Readonly<{
+ guard: ReturnType;
+}>) {
+ return (
+
}
+ fields={}
+ formActions={}
+ />,
+ );
+ expect(screen.getByRole("alert")).toBeVisible();
+ expect(screen.getByRole("textbox", { name: "Field" })).toBeVisible();
+ });
+
+ it("renders safe status variants without raw failure values", () => {
+ render(
+ {} }}
+ supportReference="SAFE-123"
+ />,
+ );
+ expect(screen.getByRole("heading", { level: 1, name: "Offline" })).toBeVisible();
+ expect(screen.getByText("SAFE-123")).toBeVisible();
+ expect(document.body).not.toHaveTextContent("stack");
+ });
+});
diff --git a/tests/e2e/accessibility.spec.js b/tests/e2e/accessibility.spec.js
index 8fb5389..ba2f662 100644
--- a/tests/e2e/accessibility.spec.js
+++ b/tests/e2e/accessibility.spec.js
@@ -2,9 +2,10 @@ import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
-for (const route of Object.values(ROUTE_REGISTRY).map((definition) =>
- definition.path === "*" ? "/not-found" : definition.path,
-)) {
+for (const route of Object.values(ROUTE_REGISTRY).map((definition) => {
+ if (definition.path === "*") return "/not-found";
+ return definition.path.replace(":resourceId", "reference-1");
+})) {
test(`@a11y ${route} has no critical or serious axe violations`, async ({
page,
}) => {
diff --git a/tests/e2e/reference-form.spec.js b/tests/e2e/reference-form.spec.js
new file mode 100644
index 0000000..7b748dd
--- /dev/null
+++ b/tests/e2e/reference-form.spec.js
@@ -0,0 +1,50 @@
+import { expect, test } from "@playwright/test";
+
+/** @param {import("@playwright/test").Page} page */
+async function openReferenceForm(page) {
+ await page.goto("/examples/reference-resources/new");
+ await page.getByRole("button", { name: "로그인 시작" }).click();
+ await expect(
+ page.getByRole("heading", { name: "Reference resource 만들기" }),
+ ).toBeVisible();
+}
+
+test("validates a reference form and focuses the first invalid field", async ({
+ page,
+}) => {
+ await openReferenceForm(page);
+ await page.getByRole("button", { name: "저장" }).click();
+
+ const firstField = page.getByRole("textbox", { name: /새 항목 이름/ });
+ await expect(firstField).toBeFocused();
+ await expect(firstField).toHaveAttribute("aria-invalid", "true");
+ await expect(page.getByRole("alert")).toContainText("입력 내용을 확인해 주세요.");
+});
+
+test("guards dirty cancellation and restores focus when writing continues", async ({
+ page,
+}) => {
+ await openReferenceForm(page);
+ await page
+ .getByRole("textbox", { name: /새 항목 이름/ })
+ .fill("Unsaved reference");
+ const cancel = page.getByRole("button", { name: "취소" });
+ await cancel.click();
+
+ await expect(
+ page.getByRole("dialog", { name: "저장하지 않은 변경이 있습니다." }),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "계속 작성" }).click();
+ await expect(cancel).toBeFocused();
+ await expect(page).toHaveURL(/\/examples\/reference-resources\/new$/);
+});
+
+test("keeps the form template within a 320px viewport", async ({ page }) => {
+ await page.setViewportSize({ width: 320, height: 720 });
+ await openReferenceForm(page);
+ const viewport = await page.evaluate(() => ({
+ clientWidth: document.documentElement.clientWidth,
+ scrollWidth: document.documentElement.scrollWidth,
+ }));
+ expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth);
+});
diff --git a/tests/features/reference-feature/reference-contract.test.ts b/tests/features/reference-feature/reference-contract.test.ts
index c1d9576..279112f 100644
--- a/tests/features/reference-feature/reference-contract.test.ts
+++ b/tests/features/reference-feature/reference-contract.test.ts
@@ -82,10 +82,14 @@ describe("reference feature boundary contracts", () => {
it("owns route, operation and query contributions in one removable contract", () => {
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.routes)).toEqual([
"REFERENCE_RESOURCE_LIST",
+ "REFERENCE_RESOURCE_DETAIL",
+ "REFERENCE_RESOURCE_FORM",
+ "REFERENCE_RESOURCE_STATUS",
]);
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.apiOperations)).toEqual([
"LIST_REFERENCE_RESOURCES",
"CREATE_REFERENCE_RESOURCE",
+ "GET_REFERENCE_RESOURCE",
]);
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.queryRegistry)).toEqual([
"REFERENCE_RESOURCE",
diff --git a/tests/features/reference-feature/reference-page.test.tsx b/tests/features/reference-feature/reference-page.test.tsx
index 20293cd..7964306 100644
--- a/tests/features/reference-feature/reference-page.test.tsx
+++ b/tests/features/reference-feature/reference-page.test.tsx
@@ -58,6 +58,14 @@ function inputWith(
createdAtLabel: null,
},
}),
+ getResource: async (resourceId) => ({
+ ok: true,
+ value: {
+ resourceId,
+ title: "Detail",
+ createdAtLabel: null,
+ },
+ }),
...overrides,
};
}
@@ -129,7 +137,7 @@ describe("reference feature page states", () => {
);
});
- it("deduplicates optimistic create and rolls back a conflict", async () => {
+ it("deduplicates create, preserves input and surfaces a conflict", async () => {
const user = userEvent.setup();
let finish:
| ((result: ReferenceResult) => void)
@@ -141,31 +149,22 @@ describe("reference feature page states", () => {
}),
);
renderReference(
- inputWith({
- listResources: async () => ({
- ok: true,
- value: [
- {
- resourceId: "existing",
- title: "Existing",
- createdAtLabel: null,
- },
- ],
- }),
- createResource,
- }),
+ inputWith({ createResource }),
+ "/examples/reference-resources/new",
);
- await screen.findByText("Existing");
- await user.type(screen.getByLabelText("새 항목 이름"), "Conflicting");
- const submit = screen.getByRole("button", { name: "추가" });
+ await screen.findByRole("heading", {
+ name: "Reference resource 만들기",
+ });
+ await user.type(
+ screen.getByRole("textbox", { name: /새 항목 이름/ }),
+ "Conflicting",
+ );
+ await user.type(screen.getByLabelText("설명"), "Keep this input");
+ const submit = screen.getByRole("button", { name: "저장" });
await user.dblClick(submit);
await waitFor(() => expect(createResource).toHaveBeenCalledOnce());
- expect(await screen.findByText("Conflicting")).toHaveAttribute(
- "data-optimistic",
- "true",
- );
- expect(screen.getByText("mutation-pending")).toBeVisible();
+ expect(screen.getByRole("button", { name: "저장 중…" })).toBeDisabled();
finish?.({
ok: false,
@@ -175,14 +174,11 @@ describe("reference feature page states", () => {
0,
),
});
+ expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
expect(
- await screen.findByRole("button", { name: "충돌 해결" }),
- ).toBeVisible();
- expect(screen.queryByText("Conflicting")).not.toBeInTheDocument();
- await user.click(screen.getByRole("button", { name: "충돌 해결" }));
- await waitFor(() =>
- expect(screen.queryByText("mutation-conflict")).not.toBeInTheDocument(),
- );
+ screen.getByRole("textbox", { name: /새 항목 이름/ }),
+ ).toHaveValue("Conflicting");
+ expect(screen.getByLabelText("설명")).toHaveValue("Keep this input");
});
it("keeps stale data visible during refresh failure and recovers on retry", async () => {
@@ -219,8 +215,7 @@ describe("reference feature page states", () => {
});
renderReference(inputWith({ listResources }));
await screen.findByText("Existing");
- await user.type(screen.getByLabelText("새 항목 이름"), "Created");
- await user.click(screen.getByRole("button", { name: "추가" }));
+ await user.click(screen.getByRole("button", { name: "새로고침" }));
expect(await screen.findByText("stale-degraded")).toBeVisible();
expect(screen.getByText("Existing")).toBeVisible();
diff --git a/tests/features/reference-feature/reference-production-vertical.test.tsx b/tests/features/reference-feature/reference-production-vertical.test.tsx
index 4d0608f..afab4d3 100644
--- a/tests/features/reference-feature/reference-production-vertical.test.tsx
+++ b/tests/features/reference-feature/reference-production-vertical.test.tsx
@@ -46,6 +46,9 @@ const releaseManifest = {
"route-examples-states": "assets/states.js",
"route-examples-auth": "assets/auth.js",
"route-reference-resources": "assets/reference.js",
+ "route-reference-resource-detail": "assets/reference-detail.js",
+ "route-reference-resource-form": "assets/reference-form.js",
+ "route-reference-resource-status": "assets/reference-status.js",
"route-not-found": "assets/not-found.js",
},
};
@@ -120,8 +123,14 @@ describe("reference feature production vertical path", () => {
"?limit=5&tags=open&tags=new",
);
- await user.type(screen.getByLabelText("새 항목 이름"), " Created ");
- await user.click(screen.getByRole("button", { name: "추가" }));
+ await user.click(screen.getByRole("button", { name: "새 항목 만들기" }));
+ await user.type(
+ await screen.findByRole("textbox", { name: /새 항목 이름/ }),
+ " Created ",
+ );
+ await user.click(screen.getByRole("button", { name: "저장" }));
+ expect(await screen.findByText("저장했습니다.")).toBeVisible();
+ await user.click(screen.getByRole("button", { name: "목록으로 돌아가기" }));
expect(await screen.findByText("Created")).toBeVisible();
expect(createRequests).toHaveBeenCalledWith({ name: "Created" });
expect(listRequests.mock.calls.length).toBeGreaterThanOrEqual(2);
diff --git a/tests/fixtures/architecture/forbidden/template-imports-application.tsx b/tests/fixtures/architecture/forbidden/template-imports-application.tsx
new file mode 100644
index 0000000..31d7702
--- /dev/null
+++ b/tests/fixtures/architecture/forbidden/template-imports-application.tsx
@@ -0,0 +1,5 @@
+import type { ApplicationApi } from "../../../../src/application/ports/in/application-api.js";
+
+export function ForbiddenTemplate(_props: { application: ApplicationApi }) {
+ return Template must not select an application use case.;
+}
diff --git a/tests/fixtures/typecheck/invalid-page-action.tsx b/tests/fixtures/typecheck/invalid-page-action.tsx
new file mode 100644
index 0000000..a8b192e
--- /dev/null
+++ b/tests/fixtures/typecheck/invalid-page-action.tsx
@@ -0,0 +1,6 @@
+import type { PageActionDefinition } from "../../../src/presentation/templates/index.js";
+
+export const activeButtonWithoutCallback = {
+ kind: "button",
+ label: "Unsafe active action",
+} satisfies PageActionDefinition;
diff --git a/tests/integration/http-client.test.js b/tests/integration/http-client.test.js
index c0208ee..31a2298 100644
--- a/tests/integration/http-client.test.js
+++ b/tests/integration/http-client.test.js
@@ -106,6 +106,45 @@ describe("shared HTTP client", () => {
});
});
+ it("projects only approved 422 issue path and code metadata", async () => {
+ server.use(
+ http.post("https://api.test/api/entities", () =>
+ HttpResponse.json(
+ {
+ success: false,
+ error: {
+ code: "INVALID_INPUT",
+ message: "raw backend secret",
+ details: {
+ issues: [
+ { path: "name", code: "REQUIRED", message: "raw field copy" },
+ { path: 42, code: "INVALID" },
+ ],
+ },
+ },
+ meta: { requestId: "request-422", traceId: "trace-422" },
+ },
+ { status: 422 },
+ ),
+ ),
+ );
+ const client = testClient({ baseUrl: "https://api.test", clock });
+ const result = await client.execute("CREATE_ENTITY", {
+ routeId: "TEST_FORM",
+ body: { name: "Valid" },
+ });
+
+ expect(result).toMatchObject({
+ ok: false,
+ error: {
+ kind: "VALIDATION_REJECTED",
+ validationIssues: [{ path: "name", code: "REQUIRED" }],
+ },
+ });
+ expect(JSON.stringify(result)).not.toContain("raw backend secret");
+ expect(JSON.stringify(result)).not.toContain("raw field copy");
+ });
+
it("guards mapper exceptions as UNKNOWN_FAILURE", async () => {
server.use(
http.get("https://api.test/api/entities", () =>
diff --git a/vitest.config.js b/vitest.config.js
index 4293e82..893094f 100644
--- a/vitest.config.js
+++ b/vitest.config.js
@@ -1,4 +1,4 @@
-import { defineConfig } from "vitest/config";
+import { configDefaults, defineConfig } from "vitest/config";
export default defineConfig({
test: {
@@ -8,6 +8,7 @@ export default defineConfig({
clearMocks: true,
mockReset: true,
testTimeout: 10_000,
+ exclude: [...configDefaults.exclude, ".tmp/**"],
coverage: {
reporter: ["text", "json-summary"],
},