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 ( +
void form.submitForm(event)} + > + navigate("/examples/reference-resources")} + > + 목록으로 돌아가기 + + } + heading={{ + eyebrow: "FormPage", + title: "Reference resource 만들기", + description: + "presentation schema, command mapper, 422/conflict와 dirty navigation 정책을 실행합니다.", + }} + errorSummary={ + + } + fields={ + <> + + + + } + formActions={ + + + + + + } + feedback={ + form.result === "success" ? ( +

저장했습니다.

+ ) : form.result === "conflict" ? ( +

충돌을 해결한 뒤 다시 제출할 수 있습니다.

+ ) : null + } + aside={ +

+ form value는 URL, storage, telemetry에 저장되지 않고 submit 시에만 + application command로 변환됩니다. +

+ } + guard={} + /> + + ); +} 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} +
  • ))}
- -
void submit(event)}> - - setName(event.currentTarget.value)} - required - /> - -
-
-
+ ); } 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 ( +
+ {children} +
+ ); +} + +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 ( + + + + + } + /> + ); +} diff --git a/src/presentation/styles/theme.css b/src/presentation/styles/theme.css index 7653fe8..e244863 100644 --- a/src/presentation/styles/theme.css +++ b/src/presentation/styles/theme.css @@ -590,6 +590,141 @@ line-height: 1.7; } + .page-template { + display: grid; + gap: 1.25rem; + min-width: 0; + } + + .page-template__breadcrumb { + padding-block-start: 1rem; + color: var(--color-content-muted); + font-size: 0.9rem; + } + + .page-template__heading { + display: grid; + min-width: 0; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 1rem; + } + + .page-template__heading .page-header { + grid-row: span 2; + min-width: 0; + } + + .page-template__status, + .page-template__actions, + .status-page__actions, + .form-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; + } + + .page-template__layout { + display: grid; + min-width: 0; + gap: 1.5rem; + } + + .page-template__layout[data-has-aside="true"] { + grid-template-columns: minmax(0, 1fr) minmax(14rem, 20rem); + } + + .page-template__content, + .page-template__aside, + .collection-page__results, + .detail-page__sections, + .form-page__fields { + min-width: 0; + } + + .page-template__aside, + .detail-page__metadata, + .collection-page__toolbar, + .form-error-summary { + border: 1px solid var(--color-border); + border-radius: var(--radius-surface); + padding: 1rem; + background: var(--color-panel); + } + + .collection-page__toolbar, + .collection-page__active-filters, + .collection-page__bulk-actions { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 0.75rem; + } + + .collection-page__result-count { + color: var(--color-content-muted); + } + + .collection-page__results, + .detail-page__sections, + .form-page__fields, + .form-page__error-summary { + display: grid; + gap: 1rem; + } + + .collection-page__pagination { + margin-block-start: 1.25rem; + } + + .detail-page__danger { + margin-block-start: 2rem; + border-top: 1px solid var(--color-danger-border); + padding-block-start: 1rem; + } + + .form-error-summary { + border-color: var(--color-danger-border); + background: var(--color-danger-surface); + } + + .form-error-summary h2 { + font-size: 1.1rem; + } + + .form-error-summary p, + .form-error-summary ul { + margin-block-end: 0; + } + + .form-actions { + padding-block-start: 1rem; + border-top: 1px solid var(--color-border); + } + + .form-actions--sticky { + position: sticky; + z-index: 10; + bottom: 0; + padding: 0.75rem; + background: color-mix(in oklch, var(--color-panel) 94%, transparent); + } + + .status-page { + max-width: 48rem; + padding-block: 2rem; + } + + .status-page__actions { + justify-content: flex-start; + } + + .status-page__support { + color: var(--color-content-muted); + } + .readiness-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -832,6 +967,24 @@ .component-grid--three { grid-template-columns: minmax(0, 1fr); } + + .page-template__heading, + .page-template__layout[data-has-aside="true"] { + grid-template-columns: minmax(0, 1fr); + } + + .page-template__heading .page-header { + grid-row: auto; + } + + .page-template__actions, + .page-template__status { + justify-content: flex-start; + } + + .form-actions--sticky { + margin-inline: calc(var(--spacing-page) * -1); + } } @media (prefers-reduced-motion: reduce) { diff --git a/src/presentation/templates/index.ts b/src/presentation/templates/index.ts new file mode 100644 index 0000000..3cb33e1 --- /dev/null +++ b/src/presentation/templates/index.ts @@ -0,0 +1 @@ +export * from "./page-templates.js"; diff --git a/src/presentation/templates/page-templates.tsx b/src/presentation/templates/page-templates.tsx new file mode 100644 index 0000000..1cba5af --- /dev/null +++ b/src/presentation/templates/page-templates.tsx @@ -0,0 +1,246 @@ +import type { ReactNode } from "react"; + +import { PageHeader } from "../components/page-header.jsx"; +import { Button } from "../components/ui/button.jsx"; + +export type PageHeading = Readonly<{ + title: string; + description?: string; + eyebrow?: string; +}>; + +export type PageActionDefinition = + | Readonly<{ + kind: "button"; + label: string; + onAction(): void; + disabled?: boolean; + variant?: "primary" | "secondary" | "danger" | "ghost"; + }> + | Readonly<{ + kind: "link"; + label: string; + href: string; + variant?: "primary" | "secondary" | "danger" | "ghost"; + }>; + +export type PageTemplateSlots = Readonly<{ + heading: PageHeading; + breadcrumb?: ReactNode; + status?: ReactNode; + actions?: readonly PageActionDefinition[]; + notices?: ReactNode; + children?: ReactNode; + aside?: ReactNode; + feedback?: ReactNode; +}>; + +export function StandardPage(props: PageTemplateSlots) { + return ( +
+ {props.breadcrumb ? ( + + ) : null} +
+ + {props.status ? ( +
{props.status}
+ ) : null} + {props.actions?.length ? ( + + ) : null} +
+ {props.notices ? ( +
{props.notices}
+ ) : null} + {props.feedback ? ( +
{props.feedback}
+ ) : null} +
+
{props.children}
+ {props.aside ? ( + + ) : null} +
+
+ ); +} + +export function CollectionPage( + props: PageTemplateSlots & + Readonly<{ + toolbar?: ReactNode; + activeFilters?: ReactNode; + resultCount?: ReactNode; + bulkActions?: ReactNode; + pagination?: ReactNode; + }>, +) { + return ( + + {props.notices} + {props.toolbar ? ( +
+ {props.toolbar} +
+ ) : null} + {props.activeFilters ? ( +
+ {props.activeFilters} +
+ ) : null} + {props.resultCount ? ( +
+ {props.resultCount} +
+ ) : null} + {props.bulkActions ? ( +
+ {props.bulkActions} +
+ ) : null} + + } + > +
{props.children}
+ {props.pagination ? ( + + ) : null} +
+ ); +} + +export function DetailPage( + props: PageTemplateSlots & + Readonly<{ + metadata?: ReactNode; + destructiveAction?: ReactNode; + }>, +) { + return ( + + {props.metadata ? ( +
+ {props.metadata} +
+ ) : null} +
{props.children}
+ {props.destructiveAction ? ( +
+ {props.destructiveAction} +
+ ) : null} +
+ ); +} + +export function FormPage( + props: PageTemplateSlots & + Readonly<{ + errorSummary?: ReactNode; + fields?: ReactNode; + formActions?: ReactNode; + guard?: ReactNode; + }>, +) { + return ( + + {props.errorSummary ? ( +
{props.errorSummary}
+ ) : null} +
{props.fields ?? props.children}
+ {props.formActions ? ( +
{props.formActions}
+ ) : null} + {props.guard} +
+ ); +} + +export type StatusPageVariant = + | "unauthenticated" + | "forbidden" + | "not-found" + | "unavailable" + | "offline" + | "maintenance" + | "unexpected"; + +export function StatusPage( + props: Readonly<{ + variant: StatusPageVariant; + heading: PageHeading; + primaryAction?: PageActionDefinition; + secondaryAction?: PageActionDefinition; + supportReference?: string; + }>, +) { + return ( +
+ + {props.primaryAction || props.secondaryAction ? ( + + ) : null} + {props.supportReference ? ( +

+ 지원 참조: {props.supportReference} +

+ ) : null} +
+ ); +} + +function PageActionBar(props: Readonly<{ + actions: readonly PageActionDefinition[]; + className?: string; +}>) { + return ( +
+ {props.actions.map((action) => + action.kind === "link" ? ( + + {action.label} + + ) : ( + + ), + )} +
+ ); +} diff --git a/tests/component/form-foundation.test.tsx b/tests/component/form-foundation.test.tsx new file mode 100644 index 0000000..349b80a --- /dev/null +++ b/tests/component/form-foundation.test.tsx @@ -0,0 +1,199 @@ +// @vitest-environment jsdom + +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { createMemoryRouter, RouterProvider, useNavigate } from "react-router-dom"; +import { z } from "zod"; +import { describe, expect, it, vi } from "vitest"; + +import { createFailure } from "../../src/contracts/errors.js"; +import { Button } from "../../src/presentation/components/ui/button.jsx"; +import { + DirtyNavigationDialog, + ErrorSummary, + Form, + FormField, + useAppForm, + useDirtyNavigationGuard, +} from "../../src/presentation/forms/index.js"; + +type Values = Readonly>; + +const schema = z + .object({ + name: z.string().trim().min(2), + note: z.string().trim().default(""), + }) + .strict(); +const defaults: Values = { name: "", note: "" }; + +function FormHarness(props: Readonly<{ + submit(command: Readonly<{ name: string; note?: string }>): Promise< + | Readonly<{ ok: true; value: string }> + | Readonly<{ ok: false; error: ReturnType }> + >; +}>) { + const form = useAppForm({ + schema, + defaultValues: defaults, + allowedServerFields: ["name", "note"], + mapToCommand(values) { + return { + name: values.name, + ...(values.note ? { note: values.note } : {}), + }; + }, + submit: props.submit, + }); + return ( +
void form.submitForm(event)}> + + + + + + {String(form.dirty)} + {form.result} + + ); +} + +describe("local form facade", () => { + it("focuses the first invalid field and performs no command", async () => { + const user = userEvent.setup(); + const submit = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: "Submit" })); + + expect(submit).not.toHaveBeenCalled(); + expect(screen.getByRole("textbox", { name: /Name/ })).toHaveFocus(); + expect(screen.getByRole("alert")).toHaveTextContent("Name"); + }); + + it("submits transformed data once and clears dirty state after success", async () => { + const user = userEvent.setup(); + let finish: ((value: { ok: true; value: string }) => void) | undefined; + const submit = vi.fn( + () => + new Promise<{ ok: true; value: string }>((resolve) => { + finish = resolve; + }), + ); + render(); + await user.type(screen.getByRole("textbox", { name: /Name/ }), " Ready "); + await user.type(screen.getByRole("textbox", { name: "Note" }), " Safe "); + + await user.dblClick(screen.getByRole("button", { name: "Submit" })); + await waitFor(() => expect(submit).toHaveBeenCalledOnce()); + expect(submit).toHaveBeenCalledWith({ name: "Ready", note: "Safe" }); + expect(screen.getByRole("button", { name: "Pending" })).toBeDisabled(); + finish?.({ ok: true, value: "saved" }); + + await waitFor(() => expect(screen.getByTestId("dirty")).toHaveTextContent("false")); + expect(screen.getByTestId("result")).toHaveTextContent("success"); + }); + + it("maps only approved 422 fields and never renders backend copy", async () => { + const user = userEvent.setup(); + const failure = createFailure( + "VALIDATION_REJECTED", + "CREATE_ENTITY", + 0, + { + validationIssues: [ + { path: "name", code: "REQUIRED" }, + { path: "serverOnly", code: "raw-secret-message" }, + ], + }, + ); + render( + ({ ok: false, error: failure })} />, + ); + await user.type(screen.getByRole("textbox", { name: /Name/ }), "Valid"); + await user.click(screen.getByRole("button", { name: "Submit" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("필수 입력값입니다."); + expect(screen.getByRole("alert")).toHaveTextContent( + "서버가 확인하지 못한 입력 항목", + ); + expect(document.body).not.toHaveTextContent("raw-secret-message"); + }); + + it("keeps conflict input out of URL and storage", async () => { + const user = userEvent.setup(); + localStorage.clear(); + window.history.replaceState({}, "", "/form-test"); + render( + ({ + ok: false, + error: createFailure("CONFLICT", "CREATE_ENTITY", 0), + })} + />, + ); + const secretLike = "token-like-do-not-copy"; + await user.type(screen.getByRole("textbox", { name: /Name/ }), secretLike); + await user.click(screen.getByRole("button", { name: "Submit" })); + + expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible(); + expect(screen.getByRole("textbox", { name: /Name/ })).toHaveValue(secretLike); + expect(window.location.href).not.toContain(secretLike); + expect(JSON.stringify(localStorage)).not.toContain(secretLike); + }); +}); + +describe("dirty navigation guard", () => { + it("blocks navigation, restores focus on stay and proceeds explicitly", async () => { + const user = userEvent.setup(); + + function GuardedPage() { + const navigate = useNavigate(); + const [dirty, setDirty] = useState(false); + const guard = useDirtyNavigationGuard(dirty); + return ( + <> + + setDirty(true)} + /> + + + + ); + } + + const router = createMemoryRouter( + [ + { path: "/", element: }, + { path: "/target", element:

Target

}, + ], + { initialEntries: ["/"] }, + ); + render(); + await user.type(screen.getByRole("textbox", { name: "Guard field" }), "x"); + const leave = screen.getByRole("button", { name: "Leave" }); + await user.click(leave); + expect( + screen.getByRole("dialog", { name: "저장하지 않은 변경이 있습니다." }), + ).toHaveAttribute("open"); + + await user.click(screen.getByRole("button", { name: "계속 작성" })); + await waitFor(() => expect(leave).toHaveFocus()); + await user.click(leave); + await user.click(screen.getByRole("button", { name: "변경 버리고 이동" })); + expect(await screen.findByRole("heading", { name: "Target" })).toBeVisible(); + }); +}); diff --git a/tests/component/page-templates.test.tsx b/tests/component/page-templates.test.tsx new file mode 100644 index 0000000..0d036c1 --- /dev/null +++ b/tests/component/page-templates.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom + +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { + CollectionPage, + DetailPage, + FormPage, + StandardPage, + StatusPage, +} from "../../src/presentation/templates/index.js"; + +describe("page template slot contracts", () => { + it("renders StandardPage minimum and full landmarks with one h1", () => { + const { rerender } = render( + Content, + ); + expect(screen.getByRole("heading", { level: 1, name: "Minimum" })).toBeVisible(); + + rerender( + Home} + status={Ready} + actions={[ + { kind: "button", label: "Action", onAction: () => {} }, + ]} + notices={

Notice

} + feedback={

Refreshing

} + aside={

Aside

} + > + Content +
, + ); + expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1); + expect(screen.getByRole("navigation", { name: "현재 위치" })).toBeVisible(); + expect(screen.getByRole("complementary", { name: "관련 정보" })).toBeVisible(); + }); + + it("places collection, detail and form state in stable slots", () => { + const { rerender } = render( + Filter} + resultCount="12 results" + pagination={Next} + > + Results + , + ); + expect(screen.getByRole("region", { name: "검색과 필터" })).toBeVisible(); + expect(screen.getByRole("navigation", { name: "페이지 탐색" })).toBeVisible(); + + rerender( +
ID
1
} + destructiveAction={} + > + Sections +
, + ); + expect(screen.getByRole("region", { name: "요약 정보" })).toBeVisible(); + expect(screen.getByRole("region", { name: "위험 작업" })).toBeVisible(); + + rerender( + Invalid

} + 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"], },