fix(core,board,ecommerce,page,gdpr): 목록 컨텍스트 왕복·엔진 렌더 파이프 결함 일괄 수정 + 공개문서 정리

세 갈래의 결함을 한 브랜치에서 정리한다.

## 목록 컨텍스트 왕복 시 URL 상태 소실 ( @jiwonpapa 님께서 제보해주셨습니다.)

목록에서 상세·형제 상세(이전/다음)·작성/수정 폼에 다녀오면 보고 있던
page/search/category/filters 가 사라지던 문제를 전 도메인에서 수정했다.

- 엔진(engine-v1.54.2): `mergeQuery: true` 만 적고 `query` 를 생략하면 병합이
 통째로 건너뛰어지던 함정을 교정 — `ActionDispatcher.handleNavigate`/`handleReplaceUrl`.
- 게시판·이커머스·페이지·회원·마이페이지·gdpr 등 9개 확장 레이아웃의 왕복 leg 전수
 적용(mergeQuery: true). 의도적 리셋(검색/필터 초기화·탭 전환·프리셋)은 면제 주석으로 구분.
- 무한스크롤 목록(브랜드·상품 공통정보·고시정보)의 새로고침이 URL 검색·정렬을 떨구던
 결함 수정.
- 재발 차단: audit 룰 `layout-list-context-navigate-merge-query`(목록 클러스터 자동 도출,
 page/필터 URL 신호 4종) + `layout-navigate-path-absolute`(navigate path 동작 키워드 금지).

## cellChildren 등 반복 렌더에서 단일 바인딩 파이프 미적용 ( @glitter-gim 님께서 제보해주셨습니다.)

목록 표의 각 칸에 넣은 날짜·숫자 서식(`{{row.x | datetime(...)}}`)이 빈 값이 되거나
서식 없는 원본으로 나오던 문제를, 표현식 판정 로직이 엔진 전역에 복제되며 갈라진
구조적 결함으로 진단하고 판정 경로를 단일화했다(engine-v1.54.3).

- `RenderHelpers`(renderItemChildren·evaluateIfCondition)·`ConditionEvaluator`·
 `DataBindingEngine.resolveObject`·`DynamicRenderer` props 5곳에 단일 바인딩 파이프 분기 추가.
- 계획: `g7-scalable-lobster.md`(렌더 경로 비대칭 결함 일괄 수정).

## 공개 문서 내부 도구 귀속 제거

release 에 포함되는 공개 문서(`docs/**`)에서 내부 audit 룰 ID 귀속 서술을
도구 비귀속 표현("정적 검사")으로 정리. 재발 차단 룰 `public-no-internal-audit-reference` 신설.

전 계층 테스트(PHPUnit·Vitest·Playwright)·회귀 테스트 동반, 버전/CHANGELOG/활성 디렉토리 동기 완료.
This commit is contained in:
HeuJung
2026-07-26 15:17:56 +09:00
parent b3efcb8366
commit b505ac7ba7
184 changed files with 3830 additions and 448 deletions
+26
View File
@@ -221,6 +221,8 @@
| `handler: "nav"` | `handler: "navigate"` |
| `handler: "setLocalState"` | `handler: "setState"` + `target: "local"` |
| `navigate` + `replace: true` (URL만 변경 시) | `handler: "replaceUrl"` |
| `navigate` `params.path: "back"` (동작 키워드로 착각) | `handler: "navigateBack"` — path 는 주소로 해석되어 조용히 `/back` 으로 이동한다 |
| `navigate` `params.url` / `href` / `to` 로 목적지 전달 | `params.path` (또는 액션 `target`) — 엔진은 이 둘만 읽는다. 다른 이름은 무시되어 목적지가 `undefined` 가 되고, 예외도 404 도 없이 버튼만 동작하지 않는다 |
| apiCall `params.target` (params 내부) | `target` 은 액션 top-level. params 내부 위치 시 URL 미해석 |
| apiCall `params.onSuccess` / `params.onError` (params 내부) | 액션 top-level. params 내부면 무시됨 |
| `refetchDataSource` `params.id` | `params.dataSourceId` 사용 |
@@ -313,6 +315,30 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
정규식 location 은 프리픽스 location 보다 먼저 매칭되므로, 정적 최적화 블록(`location ~* \.(js|css|json)$`)이 있는 서버에서는 확장자 붙은 동적 응답이 `try_files ... /index.php` 폴백 기회 없이 404 가 된다. 서버측 `AssetUrl` 과 프론트측 `assetUrl.ts` 는 동일 규칙을 공유하므로 한쪽만 바꾸면 그 자산만 404 가 된다. 상세: [routing.md](docs/backend/routing.md) "정적 확장자로 끝나는 동적 엔드포인트", [api/README.md](docs/backend/api/README.md) "자산 URL 이중 모드".
### 목록 컨텍스트 왕복 (list context round-trip)
페이지네이션 목록 화면과 그에 딸린 상세·형제 상세·작성/수정 폼·확인 모달은 하나의 목록 클러스터다. 이 클러스터 안에서의 이동은 URL 목록 상태(`page`/`search`/`category`/`filters[*]`/정렬/`per_page`)를 손실 없이 보존해야 한다.
| 금지 | 올바른 사용 |
|------|------------|
| 클러스터 내 navigate 에 `mergeQuery` 누락 | `"params": { "path": "…", "mergeQuery": true, "query": {} }` |
| 이전글/다음글 등 형제 상세 이동만 규약에서 누락 | 목록 진입 / 목록 복귀 / 형제 이동 / 폼 취소 / 삭제 후 복귀 전 leg 동일 적용 |
| 현재 값을 그대로 다시 넘기는 키 열거 (`{"del": "{{query.del ?? ''}}"}`) | `mergeQuery` 가 이미 전부 나른다 — 열거는 중복이자 누락 위험 |
| 덮어쓸 키만 남기지 않고 필터 키 전부 재열거 | 값을 바꿔야 하는 키만 남긴다 (페이지 되돌림은 `{"page": ""}`) |
| 새로고침 버튼에 `mergeQuery: false` | 새로고침은 보던 목록을 다시 부르는 것 — 병합 유지 |
| `mergeQuery` 를 표현식으로 분기 (`"{{cond}}"`) | boolean 리터럴 고정 — 분기마다 보존 여부가 갈리면 한쪽이 조용히 상태를 떨군다 |
| `"path": "/board/{slug}/write?parent_id={{id}}"` (인라인 쿼리스트링) | 인라인 쿼리는 병합 시 버려진다 → `query` 객체로 옮긴다 |
| `mergeQuery: true` + `query` 키 생략 | 의도를 드러내도록 `"query": {}` 를 함께 둔다 |
| `"query": []` (배열 리터럴) | `"query": {}` — 동작은 같아 조용히 통과하지만, 나중에 덮어쓸 키를 넣으면 그 값이 버려진다 |
| 목적지가 표현식이라 판정 불가한 이동을 무표시로 둠 (`"{{_global.shopBase}}/products"`) | 클러스터 내 이동이면 `mergeQuery: true`, 밖으로 나가는 이동이면 예외 주석으로 의도를 명시 |
| 의도적 리셋(검색·필터 초기화 / 탭 전환 / 프리셋 적용)에 `mergeQuery: true` | 리셋은 병합하지 않는다 — 병합하면 초기화 버튼이 아무 일도 하지 않는다 |
| 탭 전환(`onTabChange`)이나 겹치지 않는 다른 목록으로의 이동에 `mergeQuery: true` | 목록 정체성이 다르면 승계하지 않는다 — 남의 검색어·페이지가 얹혀 빈 화면이 열린다 |
| 면제 주석은 "병합하지 않는다" 인데 코드는 `mergeQuery: true` | 주석과 코드를 일치시킨다 (주석은 사실이 아니라 선언일 뿐) |
| 검색 실행·페이지 이동 액션에서 `query` 키를 비움 | 값을 바꾸는 액션은 그 값을 직접 넘긴다 (`{"page": "{{$args[0]}}"}`) — 병합만으로는 새 값이 전달되지 않는다 |
| `path` 없이 `query` 만 바꾸는 액션에 `mergeQuery` 누락 (탭 전환 `{"tab": …}`, 항목 선택 `{"id": …, "mode": "view"}`) | `path` 생략은 "현재 주소에 작용" 이라 목록 화면 자신이 대상 — `mergeQuery: true` 없으면 지금 걸린 목록 상태가 통째로 날아간다 |
의도적 리셋(검색 초기화 / 필터 초기화 / 탭 전환 / 프리셋 적용 / 다른 목록으로의 이동)은 예외다. 그 경우 액션 노드 `comment` 에 `audit:allow layout-list-context-navigate-merge-query <사유>` 를 남겨 의도를 코드에 기록한다. 상세: [actions-handlers-navigation.md "목록 컨텍스트 왕복 규약"](docs/frontend/actions-handlers-navigation.md)
### 중첩 리소스 스코프 / 계층 무결성
| 금지 | 올바른 사용 |
+2
View File
@@ -28,6 +28,8 @@
### Fixed
- 목록 표의 각 칸에 넣은 날짜·숫자 서식이 적용되지 않아 값이 비어 보이던 문제를 수정했습니다. 같은 서식을 일반 화면에서 쓰면 정상이었지만 목록 표의 칸 안에서는 값이 사라지거나(날짜 서식) 서식이 빠진 원래 값이 그대로 나왔습니다(숫자·대문자 서식). 목록 표와 카드 목록, 펼침 영역 등 반복해서 그려지는 모든 자리에서 서식이 정상 적용됩니다. (#87 @glitter-gim 님께서 제보해주셨습니다.)
- 목록 화면에서 글이나 항목을 열어 보고 돌아올 때 보고 있던 페이지·검색어·필터가 사라지던 문제를 화면 엔진 차원에서 함께 수정했습니다. '현재 주소의 조건을 유지한다'고 지정해도 덧붙일 값이 없으면 유지가 아예 동작하지 않아 조건이 통째로 사라지던 동작을 바로잡았습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 데이터베이스 연결이 일시적으로 끊기거나 확장 설치가 중간에 실패한 직후 확장 목록을 다시 만들면, 설치된 모듈·플러그인 정보가 통째로 비워지면서 게시판·쇼핑몰 등 모든 확장 화면이 500 오류를 내던 문제를 수정했습니다. 이제 확장 폴더가 그대로 남아 있는데 목록만 비어 있으면 기존 정보를 지우지 않고 보존하며, 왜 갱신하지 않았는지 로그에 남깁니다.
- 확장 목록이 손상된 상태에서 복구 명령을 실행하면 목록은 되살아나지만 확장 기능 연결이 비어 있는 채로 남아, 명령을 두 번 실행해야 완전히 복구되던 문제를 수정했습니다. 이제 한 번 실행으로 모두 복구됩니다.
- 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다.
+1 -1
View File
@@ -300,7 +300,7 @@ $this->logActivity('schedule.auto_run', [
번들 일본어 언어팩(`lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php`)도 함께 동기화한다.
```text
audit 룰: activity-log-listener-i18n-coverage (코어/모듈/플러그인 ActivityLog 리스너 전 영역 자동 검출)
정적 검사: 코어/모듈/플러그인 ActivityLog 리스너 전 영역에서 키 누락 자동 검출
```
---
+7 -7
View File
@@ -308,7 +308,7 @@ php artisan api:docgen --scope=core --dry-run
같은 원리로 `is_default` / `status=active` 같은 승격·활성 플래그를 샘플에 붙이지 않는다. 샘플 레코드를
만들기 전에 "이 조합이 전역 설정을 읽는 쿼리에 걸리는가" 를 확인한다.
자동 차단: audit 룰 `apidoc-sample-no-global-active-language-pack` (severity: error).
자동 차단: 정적 검사 대상 (위반 시 차단).
### 설명 백필 커맨드 (`api:docgen-backfill-*`)
@@ -338,12 +338,12 @@ php artisan api:docgen-backfill-fields
- 트리거: `app/Http/Controllers/**`, `routes/api.php`, `app/Http/Requests/**`, `app/Http/Resources/**`
(+ 확장 대응 경로) 편집.
- 절차: 코드 변경 → `api:docgen --scope=...` 재실행 → `@generated` 블록 갱신 → 신규 TODO 서술 채움.
- 검증: `api:docgen --check` 로 drift 0 확인. audit 룰 `api-doc-coverage` 가 변경셋에 대응 문서
동반 여부를 검사한다. severity 는 **대상별**로 부여된다 — 문서가 완비된 대상은 `error`(문서
미동반 변경 차단), 진행 중 대상은 `warn`. 코어(`docs/backend/api/`)는 2026-07-08 완료로
`error` 승격됨. 즉 코어 API 표면(`routes/api.php`·`app/Http/{Controllers,Requests,Resources}/**`)을
변경하면서 코어 API 문서를 함께 갱신하지 않으면 세션 종료 시 차단된다. 나머지 확장은 문서 완비
시 순차 승격된다(룰의 `ENFORCED_TARGETS`).
- 검증: `api:docgen --check` 로 drift 0 확인. 정적 검사가 변경셋에 대응 문서
동반 여부를 검사한다. 강제 수준은 **대상별**로 다르다 — 문서가 완비된 대상은 차단, 진행 중
대상은 경고에 그친다. 코어(`docs/backend/api/`)는 2026-07-08 문서 완비로 차단 대상이 됐다.
즉 코어 API 표면(`routes/api.php`·`app/Http/{Controllers,Requests,Resources}/**`)을
변경하면서 코어 API 문서를 함께 갱신하지 않으면 그 변경은 통과하지 못한다. 나머지 확장은 문서
완비 시 순차로 차단 대상에 편입된다.
---
+1 -1
View File
@@ -159,7 +159,7 @@ target 이 `/` 로 시작하면 **URI 패턴**(`$request->path()` 매칭), 아
| 확장 SP 에서 `aliasMiddleware()` 직접 호출 | `getMiddleware()` 선언 |
| 확장 라우트 파일에서 자기 `Http\Middleware\` FQCN 을 `->middleware(Foo::class)` 로 부착 | `getMiddleware()` 선언 |
자동 차단: audit 룰 `extension-middleware-declarative-registration`, `extension-route-middleware-alias-reference`, `extension-middleware-gate-cache-coverage` (모두 error). 서드파티 확장의 구방식은 Laravel 공식 API 라 런타임 차단은 없고 문서로 안내한다.
자동 차단: 위 규율은 모두 정적 검사 대상 (위반 시 차단). 서드파티 확장의 구방식은 Laravel 공식 API 라 런타임 차단은 없고 문서로 안내한다.
---
+1 -1
View File
@@ -379,7 +379,7 @@ public function getNotificationDefinitions(): array
### 금지 사항
- 모듈/플러그인 측에 별도 `*NotificationDefinitionSeeder.php` 파일을 두면 안 됩니다. 동일 데이터를 두 곳에서 유지하면 SSoT 가 깨지며, audit 룰 `notification-seeder-orphan` 이 차단합니다.
- 모듈/플러그인 측에 별도 `*NotificationDefinitionSeeder.php` 파일을 두면 안 됩니다. 동일 데이터를 두 곳에서 유지하면 SSoT 가 깨지며, 정적 검사가 차단합니다.
- 코어는 `database/seeders/NotificationDefinitionSeeder.php` 가 fresh install 진입점으로 유지되지만, 데이터 자체는 `config/core.php` 가 SSoT 입니다 (시더는 config 를 읽기만 함).
---
+1 -1
View File
@@ -141,7 +141,7 @@ URL 을 만드는 쪽도 문자열로 직접 조립하지 않는다. 서버는 `
`resources/js/core/support/assetUrl.ts` 를 경유해야 현재 모드에 맞는 형태가 나온다. 두 구현은 동일 규칙을
공유하므로 한쪽만 바꾸면 서버가 만든 URL 과 클라이언트가 만든 URL 이 어긋나 그 자산만 404 가 된다.
자동 차단: audit 룰 `dynamic-route-static-extension` (error). 면제는 인라인 주석
자동 차단: 정적 검사 대상 (위반 시 차단). 면제는 인라인 주석
`// audit:allow dynamic-route-static-extension reason: ...`.
두 형태는 모두 영구 유지한다 — 확장자 형태를 제거하면 URL 을 하드코딩한 서드파티 확장이 깨진다.
+1 -1
View File
@@ -535,7 +535,7 @@ $viewData = HookManager::applyFilters('core.seo.filter_view_data', $viewData, [
### 도메인 스키마 ownership — seoOgDefaults / seoTwitterDefaults / seoStructuredData
모듈/플러그인은 `AbstractModule` / `AbstractPlugin` 의 다음 메서드를 오버라이드하여 자기 도메인의 OG/Twitter/JSON-LD 를 owned 한다. 레이아웃 JSON 직접 선언은 페이지별 override 가 꼭 필요할 때만 사용 (도메인 스키마는 모듈로 이전 — `seo-domain-schema-in-layout` audit 룰이 자동 차단).
모듈/플러그인은 `AbstractModule` / `AbstractPlugin` 의 다음 메서드를 오버라이드하여 자기 도메인의 OG/Twitter/JSON-LD 를 owned 한다. 레이아웃 JSON 직접 선언은 페이지별 override 가 꼭 필요할 때만 사용 (도메인 스키마는 모듈로 이전 — `seo-domain-schema-in-layout` 정적 검사가 자동 차단).
```php
class EcommerceModule extends AbstractModule
+1 -1
View File
@@ -1095,7 +1095,7 @@ public function findOrFail(string $slug, int $id, ?int $postId = null): Comment
| --- | --- |
| 방어 위치 | Repository where 절 (SSoT) + Service 가 상위 ID 를 전달 |
| 파라미터 형태 | **선택 파라미터**(`?int $postId = null`) — 필수화는 Interface breaking 이라 이 확장에 의존하는 확장을 전부 흔든다 |
| 전달 누락 방지 | 컨트롤러가 라우트 파라미터를 실제로 소비하는지 audit 룰 `controller-unused-route-param` (warn) 이 감시 |
| 전달 누락 방지 | 컨트롤러가 라우트 파라미터를 실제로 소비하는지 정적 검사가 감시 (경고) |
| 응답 코드 | 조회 실패이므로 404. 요청 본문 배열 항목의 스코프 위반은 422 (validation.md "배열 항목의 상위 스코프") |
| 공통 추상화 | 도입하지 않는다 — 모듈마다 상위 키와 조회 경로가 다르고(`board_id`+`post_id` / `product_id` / `page_id` / `order_id`), 코어 표면이 늘면 확장 버전 제약 동기화가 연쇄된다 |
@@ -7,7 +7,7 @@
2. helper: localize_catalog_field($field, $langKey) — 단일 함수 호출
3. 모듈/플러그인 Service 가 자기 빌드 메서드 안에서 직접 호출
4. lang key segment 는 settings JSON 의 식별자(id/code/key) 와 동일
5. audit 룰 settings-multilingual-enrichment-coverage 가 호출 누락 검출 (warning)
5. 정적 검사가 호출 누락 검출 (경고)
```
## 사용법
@@ -108,9 +108,9 @@ helper 동작:
운영자가 admin UI 에서 ja 라벨을 직접 입력했다면 보존. 입력 안한 locale 만 lang pack 으로 채움.
## audit 룰
## 정적 검사
`settings-multilingual-enrichment-coverage` (severity: warning):
경고 수준으로 다음을 검출한다:
- `{modules|plugins}/_bundled/*/config/settings/defaults.json` 에 다국어 카탈로그 entry 발견
- 같은 확장의 `src/Services/*.php` 안에 `localize_catalog_field` 호출 없음
+5 -5
View File
@@ -6,7 +6,7 @@
1. 다국어 JSON 컬럼(name 등)을 시드하는 확장 entity 시더는 TranslatableSeederInterface 구현 필수
2. App\Concerns\Seeder\HasTranslatableSeeder trait 사용 → resolveTranslatedDefaults() 가 활성 lang pack 머지 자동 처리
3. 시더는 getDefaults() 에 ko/en 만 정의, ja 등은 lang pack seed/{entity}.json 자동 빌드 (build-language-pack-ja.cjs)
4. audit 룰 seeder-translation-filter 가 누락 검출 (error severity)
4. 정적 검사가 누락 검출 (위반 시 차단)
5. 코어 시더(NotificationDefinitionSeeder 등)는 별도 LoadsConfigSeedWithLangPackFilter trait 패턴 (config 기반)
```
@@ -14,7 +14,7 @@
활성 언어팩의 `seed/{entity}.json` 파일을 entity 시더가 머지하는 인프라가 7.0.0-beta.4 부터 정식화되었으나, 시더가 직접 `HookManager::applyFilters()` 를 호출하는 패턴이라 누락 시 회귀가 발생했다. (예: 7.0.0-beta.4 직전 `ShippingCarrierSeeder` 에서 ja 라벨 fallback 회귀)
7.0.0-beta.5 부터 `TranslatableSeederInterface` + `HasTranslatableSeeder` trait 로 컴파일 타임 / audit 단계에서 강제하도록 인프라화.
7.0.0-beta.5 부터 `TranslatableSeederInterface` + `HasTranslatableSeeder` trait 로 컴파일 타임 / 정적 검사 단계에서 강제하도록 인프라화.
## 인터페이스
@@ -82,9 +82,9 @@ class ShippingCarrierSeeder extends Seeder implements TranslatableSeederInterfac
- `getMatchKey()` 가 반환하는 키 조합이 시드 항목의 식별자로 사용된다.
- `getDefaults()` 가 ko 기준 다국어 필드를 반환한다.
## audit 룰
## 정적 검사
`seeder-translation-filter` (severity: error):
다음을 차단한다:
- 코어 시더 (NotificationDefinitionSeeder/IdentityMessageDefinitionSeeder) — `applyFilters('seed.X.translations', ...)` 발화 필수
- ModuleManager/PluginManager — `sync*Definitions/*Messages` 본문에 `applyFilters` 발화 필수
- 확장 entity 시더 (`(modules|plugins)/_bundled/<id>/database/seeders/<X>Seeder.php`) — 다국어 JSON 컬럼 시드 시 `TranslatableSeederInterface` 구현 + `HasTranslatableSeeder` trait 사용 필수
@@ -99,7 +99,7 @@ class ShippingCarrierSeeder extends Seeder implements TranslatableSeederInterfac
- **확장 entity 시더** (BoardType/ShippingCarrier 등): 시더 자체가 데이터 SSoT → `TranslatableSeederInterface` + `HasTranslatableSeeder`
- **코어 시더** (NotificationDefinition/IdentityMessageDefinition/Permission/Role/Menu): `config/core.php` 가 데이터 SSoT → 시더는 `LoadsConfigSeedWithLangPackFilter` trait 의 `loadConfigSeed(<configKey>, <filterName>)` 호출
두 패턴 모두 `seed.{X}.translations` 필터를 발화하므로 audit 룰 통과는 동일.
두 패턴 모두 `seed.{X}.translations` 필터를 발화하므로 정적 검사 결과는 동일.
## 참고
+3 -3
View File
@@ -533,7 +533,7 @@ class TestMailRequest extends FormRequest
'allowed_extensions' => ['exclude_if:use_file_upload,false', 'sometimes', 'required', 'array', 'min:1'],
```
자동 차단: `formrequest-store-update-conditional-symmetry`(error), `formrequest-sometimes-array-min-inert`(warning).
자동 차단: 정적 검사 대상. store/update 사이의 조건부 검증 비대칭은 차단되고, `sometimes` + `array` + `min` 조합이 무효화되는 형태는 경고로 보고된다.
---
@@ -1060,7 +1060,7 @@ Rule::exists("board_{$slug}_posts", 'id'),
| Service 2차 방어 | 검증을 우회하는 경로(시더 / 훅 / 기존 오염 데이터)를 위해 재귀 함수에 방문 ID 가드를 둔다. 무한 루프 대신 유한 실패 + 로그 |
| 코어 Rule 재사용 | `App\Rules\NotCircularParent` 는 `App\Models\Menu` 하드코딩이다. 다른 모델에는 모듈 로컬 Rule 을 신설한다 (코어 시그니처 변경 = 확장 버전 제약 연쇄) |
자동 차단: audit 룰 `parent-id-requires-cycle-rule` (error).
자동 차단: 정적 검사 대상 (위반 시 차단).
---
@@ -1153,7 +1153,7 @@ FormRequest 책임).
### 자동 검증
audit 룰 `formrequest-direct-data-access` 가 FormRequest 내 DB facade / Model
정적 검사가 FormRequest 내 DB facade / Model
정적 호출 / 영속 메서드(`save`/`saveQuietly`/`forceDelete`) 직접 호출을 자동
차단한다. `Rule::exists(Model::class, ...)` 같은 validation rule helper 는 면제.
+1 -1
View File
@@ -190,7 +190,7 @@ public function up(): void
#### `->change()` 패턴의 멱등성
`->change()` 는 이미 존재하는 컬럼의 타입/제약 변경이므로 컬럼 부재 fatal 위험은 없다. 단, MariaDB/MySQL 의 일부 ALTER 가 비-멱등인 경우 (예: enum 값 추가 후 재실행) 가 있으므로 마이그레이션 작성자가 의도 검증 필요. audit rule `migration-idempotency-guard` 는 `->change()` 라인을 자동 제외하므로 false positive 미발생.
`->change()` 는 이미 존재하는 컬럼의 타입/제약 변경이므로 컬럼 부재 fatal 위험은 없다. 단, MariaDB/MySQL 의 일부 ALTER 가 비-멱등인 경우 (예: enum 값 추가 후 재실행) 가 있으므로 마이그레이션 작성자가 의도 검증 필요. 정적 검사는 `->change()` 라인을 자동 제외하므로 false positive 미발생.
#### 검증 절차
+1 -1
View File
@@ -152,7 +152,7 @@ public function register(): void
}
```
audit 룰 `extension-no-global-cache-rebind` 가 자동 차단한다. 면제 표지 불허.
정적 검사가 자동 차단한다. 면제 표지 불허.
### 표준 패턴 — contextual binding
+5 -3
View File
@@ -292,10 +292,12 @@ setState(selectedX) → apiCall(changelog)
### 자동 검증
- `host-lang-change-requires-changelog` (warn) — 변경셋에 호스트 lang 파일이 있는데 그 호스트 CHANGELOG 가 없으면 경고
- `lang-duplicate-array-key` (error) — 같은 레벨 중복 키 차단. 뒤 선언이 앞을 덮어 앞 블록 전용 키가 런타임에 소실되고 원문 키가 노출되는 것을 막는다. 정정은 항상 **두 블록 병합** (한쪽만 삭제하면 그쪽 키가 사라진다)
정적 검사가 다음 두 가지를 본다.
언어팩 패키지(`lang-packs/_bundled/**`)는 별도 룰(`lang-pack-*`)이 담당하므로 본 절의 대상이 아니다.
- 변경셋에 호스트 lang 파일이 있는데 그 호스트 CHANGELOG 가 없으면 경고한다.
- 같은 레벨의 중복 배열 키는 차단한다. 뒤 선언이 앞을 덮어 앞 블록 전용 키가 런타임에 소실되고 원문 키가 노출되기 때문이다. 정정은 항상 **두 블록 병합** — 한쪽만 삭제하면 그쪽 키가 사라진다.
언어팩 패키지(`lang-packs/_bundled/**`)는 별도 검사가 담당하므로 본 절의 대상이 아니다.
---
+5 -5
View File
@@ -75,15 +75,15 @@ editor-spec.json 이 커지면(코어 admin 템플릿은 단일 파일 18,000줄
- `icon-picker` — 아이콘 prop(예 `iconName`/`triggerIcon`)을 카탈로그 그리드에서 검색·선택한다. **카탈로그는 템플릿 소유**(라이브러리 종속) — Font Awesome 등 아이콘셋을 쓰는 템플릿이 `G7Core.layoutEditor.registerWidget('icon-picker', ...)` 로 자기 위젯을 등록한다(코어는 위젯명만 디스패치, 아이콘 라이브러리 토큰 0). 프리뷰 폴백은 `preview.html`(코어 비해석) → `preview.className` → raw value 순. 카탈로그 미공급 시 자유 텍스트 입력으로 디그레이드.
- `options-list` — `Select`/`RadioGroup` 등의 정적 `options` 배열(value/label 행)을 추가/삭제/이동 편집한다. 값이 `{{바인딩}}` 이면 "바인딩됨(코드 편집)" 디그레이드(덮어쓰기 차단). 데이터소스 바인딩(dataProps `options`)과 직교 공존(위 "선택지 직교 완화").
- `array-items` / `array-group` / `array-cell-tree` / `number-list` — 정적 배열 prop 편집(탭/차트 데이터/카드 컬럼 등). `nodeEditor` 슬롯 또는 propControl 로 결선한다(아래 "데이터 정의 빌트인" 참조).
- `i18n-text` vs `text` — **표시 텍스트**(사용자가 화면에서 읽는 라벨/메시지/플레이스홀더)는 `i18n-text` 를 쓴다. `i18n-text` 는 평문 입력 시 `createCustomKey` 로 다국어 키를 자동 생성해 `$t:custom.*` 로 치환하고(미리보기에 raw 키 미노출), 🌐 펼침으로 ko/en/ja 동시 편집을 제공하며, `{{바인딩}}` 값이면 읽기전용 디그레이드한다(공통 위젯 `I18nTextField`). **비-표시값**(URL/id/수치/색/통화코드/separator 등)만 `text` 를 쓴다. 표시 텍스트에 `text` 를 쓰면 raw `$t:` 노출·다국어 누락 회귀가 발생한다(audit `editor-array-label-field-i18n` 가 배열 라벨 필드의 `text` 사용을 차단).
- `i18n-text` vs `text` — **표시 텍스트**(사용자가 화면에서 읽는 라벨/메시지/플레이스홀더)는 `i18n-text` 를 쓴다. `i18n-text` 는 평문 입력 시 `createCustomKey` 로 다국어 키를 자동 생성해 `$t:custom.*` 로 치환하고(미리보기에 raw 키 미노출), 🌐 펼침으로 ko/en/ja 동시 편집을 제공하며, `{{바인딩}}` 값이면 읽기전용 디그레이드한다(공통 위젯 `I18nTextField`). **비-표시값**(URL/id/수치/색/통화코드/separator 등)만 `text` 를 쓴다. 표시 텍스트에 `text` 를 쓰면 raw `$t:` 노출·다국어 누락 회귀가 발생한다(정적 검사가 배열 라벨 필드의 `text` 사용을 차단).
### componentCapabilities — 컴포넌트별 편집 역량
컴포넌트별로 `propControls`(속성 탭 컨트롤 화이트리스트 — `controls.json` 의 `apply.type==="propValue"` 컨트롤 참조, 비-스타일 prop 편집) / `dataProps`(데이터 연결 선언 — 아래) / `styleControls`(스타일 탭 컨트롤 화이트리스트) / `advanced`(고급 속성) / `events`(액션 편집 이벤트 — 미선언 시 동작 탭 숨김) / `flexEditor`(`container`/`item`/`auto` — 정렬 박스 편집 역할) / `visibilityCondition`(표시조건 탭 허용 여부) / `nodeEditor`·`canvasOverlay`(구조 에디터 일반 슬롯 `{kind,params}` — 종류별 고정 키 금지, kind 핸들러 레지스트리 디스패치)을 선언한다.
**데이터 연결(`dataProps`)**: "데이터를 바라보는 prop"(컬렉션의 `data`/`items`/`options` 배열, 입력 `value`·텍스트 등 단일값)을 데이터소스/상태에 바인딩하는 선언이다. 각 항목은 `{propKey, shape:'scalar'|'array', label?, itemFields?, required?, sources?}`. [속성] 탭 최상단 "데이터 연결" 영역이 각 항목을 검색형 바인딩 행으로 렌더하고, 선택 시 `{{<source>.<path>}}` 가 `node.props[propKey]` 에 기입된다(런타임 동일). 데이터 prop 은 **항상 바인딩 모드**(정적↔바인딩 토글 없음). **구조/수치 prop**(열 수·간격·페이지크기·크기)·**enum 선택**(variant/position)·**boolean config**(required/disabled)는 dataProps 비대상 — `propControls` 정적 편집이다. `dataProps` 와 `propControls` 는 직교한다(한 prop 은 한쪽만). `shape: 'array'` 면 `itemFields`(항목 객체 주요 필드)를 권장한다(검색 피커 항목 미리보기). `shape: 'object'` 는 컴포넌트가 객체 1건을 통째로 바라보는 prop(예 ProductCard.product, Avatar.author)용이다. 후보 풀은 편집기 샘플 데이터(data_sources `sampleData` shape + `sampleGlobal`) 가 SSoT 다(런타임 응답 추측 금지). audit `editor-datapropspec-shape-valid` 가 스키마를 가드한다.
**데이터 연결(`dataProps`)**: "데이터를 바라보는 prop"(컬렉션의 `data`/`items`/`options` 배열, 입력 `value`·텍스트 등 단일값)을 데이터소스/상태에 바인딩하는 선언이다. 각 항목은 `{propKey, shape:'scalar'|'array', label?, itemFields?, required?, sources?}`. [속성] 탭 최상단 "데이터 연결" 영역이 각 항목을 검색형 바인딩 행으로 렌더하고, 선택 시 `{{<source>.<path>}}` 가 `node.props[propKey]` 에 기입된다(런타임 동일). 데이터 prop 은 **항상 바인딩 모드**(정적↔바인딩 토글 없음). **구조/수치 prop**(열 수·간격·페이지크기·크기)·**enum 선택**(variant/position)·**boolean config**(required/disabled)는 dataProps 비대상 — `propControls` 정적 편집이다. `dataProps` 와 `propControls` 는 직교한다(한 prop 은 한쪽만). `shape: 'array'` 면 `itemFields`(항목 객체 주요 필드)를 권장한다(검색 피커 항목 미리보기). `shape: 'object'` 는 컴포넌트가 객체 1건을 통째로 바라보는 prop(예 ProductCard.product, Avatar.author)용이다. 후보 풀은 편집기 샘플 데이터(data_sources `sampleData` shape + `sampleGlobal`) 가 SSoT 다(런타임 응답 추측 금지). 정적 검사가 스키마를 가드한다.
**선택지(options) 직교 완화**: 선택 컴포넌트(Select/RadioGroup/TagInput/SearchableDropdown 등)의 `options` 배열은 정적 목록 직접 편집(propControls `selectOptions` → `apply.propKey: "options"`)과 데이터소스 바인딩(dataProps `options` array)을 **둘 다** 노출한다 — 같은 `options` prop 에 정적·동적 두 입력을 모두 제공한다(부록6 "한 prop 은 한쪽만" 규칙의 명시적 완화). audit 룰의 propControls 비교는 controlKey 문자열 기준이라 `selectOptions`(controlKey) ≠ `options`(dataProps propKey)로 자동 통과한다.
**선택지(options) 직교 완화**: 선택 컴포넌트(Select/RadioGroup/TagInput/SearchableDropdown 등)의 `options` 배열은 정적 목록 직접 편집(propControls `selectOptions` → `apply.propKey: "options"`)과 데이터소스 바인딩(dataProps `options` array)을 **둘 다** 노출한다 — 같은 `options` prop 에 정적·동적 두 입력을 모두 제공한다(부록6 "한 prop 은 한쪽만" 규칙의 명시적 완화). 정적 검사의 propControls 비교는 controlKey 문자열 기준이라 `selectOptions`(controlKey) ≠ `options`(dataProps propKey)로 자동 통과한다.
**반복(iteration) 데이터 연결**: `node.iteration.source`(반복 렌더링의 배열 소스)는 `props` 가 아니라 **노드 최상위 구조 키**라 컴포넌트 capability(`dataProps`)와 무관하다. iteration 은 Div/Span/Button/Li 등 거의 모든 컴포넌트에 붙을 수 있으므로, 편집기는 `dataProps` 선언 여부와 별개로 **iteration 을 가진 모든 노드**에 [속성] 탭 최상단 "반복 데이터 연결" 공용 영역(`IterationBindingSection`)을 노출한다(항상 array shape 바인딩). `item_var`/`index_var` 는 안쪽 자식 바인딩과 연동되어 읽기전용 힌트로만 표시한다(변경은 코드 편집).
@@ -102,7 +102,7 @@ editor-spec.json 이 커지면(코어 admin 템플릿은 단일 파일 18,000줄
**역할 식별 (이름 가정 금지)**: 코어는 컴포넌트 **이름**(`name==="Table"`)으로 표/목록/배열을 식별하지 않는다. 어떤 컴포넌트든 capability 의 `nodeEditor.kind`/`canvasOverlay.kind`/`dataProps`/`flexEditor` **역할 선언**으로 편집 동작을 결정한다. 따라서 `div`-그리드로 만든 표도 `nodeEditor:{kind:"table",params:{...역할 매핑}}` 만 선언하면 표 에디터가 붙고, 커스텀 목록 컴포넌트도 `nodeEditor:{kind:"children"}` 으로 목록 에디터가 붙는다. `params` 의 역할 매핑(rowContainer/row/cell 등)은 컴포넌트 이름이 아니라 그 컴포넌트가 어떤 자식 구조를 쓰는지를 적는다.
**데이터 정의 빌트인(옵션/배열/차트 수동 데이터)**: 정적으로 편집 가능한 "데이터 정의" 표면(Select.options, TabNavigation.tabs, BarChart.labels+datasets, DonutChart.data, CardGrid.cardColumns, DynamicFieldList.columns, SocialLoginButtons.providers 등)은 모두 `nodeEditor`(array/array-group/array-cell-tree) 또는 `propControls`(options-list) 로 편집 가능하게 선언한다. 전 draggable 컴포넌트는 데이터 표면을 가지면 편집 슬롯을 보유하거나(누락 0), 보유하지 않으면 비대상 allowlist(flat scalar / 표시 텍스트 / 레이아웃 / 런타임 scalar 바인딩전용)에 등록되어야 한다 — audit `editor-all-draggable-data-editable` 가 둘 다 아닌 draggable 을 차단한다.
**데이터 정의 빌트인(옵션/배열/차트 수동 데이터)**: 정적으로 편집 가능한 "데이터 정의" 표면(Select.options, TabNavigation.tabs, BarChart.labels+datasets, DonutChart.data, CardGrid.cardColumns, DynamicFieldList.columns, SocialLoginButtons.providers 등)은 모두 `nodeEditor`(array/array-group/array-cell-tree) 또는 `propControls`(options-list) 로 편집 가능하게 선언한다. 전 draggable 컴포넌트는 데이터 표면을 가지면 편집 슬롯을 보유하거나(누락 0), 보유하지 않으면 비대상 allowlist(flat scalar / 표시 텍스트 / 레이아웃 / 런타임 scalar 바인딩전용)에 등록되어야 한다 — 정적 검사가 둘 다 아닌 draggable 을 차단한다.
**코어 제공 속성(요소 ID) + opt-out**: 코어는 모든 draggable 컴포넌트의 [속성] 탭 최상단에 "요소 ID" 컨트롤을 일괄 제공한다(값 = 표준 `node.props.id`, 강제 DOM 주입 없음 — 컴포넌트 passthrough 책임). 기존 `elemId`(→id) 같은 템플릿 propControl 은 코어로 이전(중복 선언 금지 — 코어 우선). 인라인 루트가 없는 컴포넌트(서드파티 모달/Portal)는 capability 에 `"coreProps": false` 로 opt-out 한다. `"coreProps": ["id"]` 처럼 부분집합 선언도 가능(미선언 = 코어 기본 전체). 코어 id 컨트롤은 `{{바인딩}}` 값이면 "바인딩됨(코드 편집)" 디그레이드, HTML 안전 문자만 허용(한글·공백 자동 제거). 컴포넌트 측 id passthrough 규약은 `docs/frontend/components-types.md` "요소 id 패스스루" 참조.
@@ -178,7 +178,7 @@ editor-spec.json 이 커지면(코어 admin 템플릿은 단일 파일 18,000줄
`stateLabels` 가 상태값(`_global`/`query` 등) 명칭을 editor-spec 카탈로그에서 다는 것과 달리, **data_source 의 친화 명칭은 그 소스를 선언한 레이아웃 JSON 의 `data_sources[]` 항목에 `label_key` 로 직접 단다**(`$t:` 다국어 키 — 예 `"label_key": "$t:editor.data_source.products"`). 데이터 연결(`dataProps`) 검색 피커가 data_source 후보를 표시할 때 현재 로케일의 친화 명칭으로 보여 주며, 미지정 시 소스 id 로 폴백한다. 명칭 키는 편집 대상 템플릿의 편집기 i18n(`lang/partial/{locale}/editor.json` 의 `data_source.<id>`)에 정의한다 — 언어팩으로 로케일이 동적 추가되면 자동 대응한다.
`label_key` 는 레이아웃 편집기의 **데이터 소스 편집기**(툴바 `⚙데이터` → `DataSourcesPanel`)가 관리한다. 번들 템플릿의 모든 레이아웃은 모든 data_source 에 `label_key` 를 보유해야 하며(누락 0), audit `data-source-label-key-coverage` 가 신규 레이아웃의 누락·비-`$t:` 값을 차단한다.
`label_key` 는 레이아웃 편집기의 **데이터 소스 편집기**(툴바 `⚙데이터` → `DataSourcesPanel`)가 관리한다. 번들 템플릿의 모든 레이아웃은 모든 data_source 에 `label_key` 를 보유해야 하며(누락 0), 정적 검사가 신규 레이아웃의 누락·비-`$t:` 값을 차단한다.
### darkMode — 다크 모드 표현 (소형, manifest 인라인 권장)
+2 -2
View File
@@ -230,7 +230,7 @@ class ProductCacheInvalidationListener implements HookListenerInterface
### Listener 데이터 접근 규정
Listener 는 thin orchestrator 로 동작하며 영속/도메인 책임은 Service / Repository 가 갖는다. 다음 패턴은 audit 가 차단한다.
Listener 는 thin orchestrator 로 동작하며 영속/도메인 책임은 Service / Repository 가 갖는다. 다음 패턴은 정적 검사가 차단한다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
@@ -282,7 +282,7 @@ $products = $this->productRepository->findByIdsKeyed($ids);
#### 면제 (allowlist)
다음과 같은 의도된 예외는 인라인 주석으로 명시한다 (audit 가 해당 라인을 건너뜀).
다음과 같은 의도된 예외는 인라인 주석으로 명시한다 (정적 검사가 해당 라인을 건너뜀).
```php
// audit:allow listener-direct-db-facade reason: 동적 modelClass dispatch (description resolver 의 unified ID→name 변환)
+1 -1
View File
@@ -69,7 +69,7 @@ lang/
- `core.<영역>.*` — 향후 코어 UI 추가 시
호스팅 템플릿/모듈/플러그인의 lang JSON 은 root 에 `core` 키를 정의하지 않는다.
audit 룰 `core-frontend-i18n-location` 이 정적으로 차단한다.
정적 검사가 차단한다.
### 런타임 병합 흐름
+1 -1
View File
@@ -410,7 +410,7 @@ GET /api/plugins/bundle.css?v={version}
### 병합 규율
| 규율 | 내용 | audit 룰 |
| 규율 | 내용 | 정적 검사 |
|------|------|---------|
| priority 순서 | manifest `loading.priority` 오름차순만. 확장 이름 하드코딩 금지 | (선언형) |
| `\n;\n` 구분자 | IIFE 사이는 `\n;\n`(JS)/`\n`(CSS). 미사용 시 ASI 붕괴 → 전체 파싱 에러 | `extension-bundle-concat-separator` |
+2 -2
View File
@@ -392,10 +392,10 @@ class FooPluginServiceProvider extends BasePluginServiceProvider
### 금지 사항
- **글로벌 `CacheInterface` / `StorageInterface` 바인딩 재정의 금지** — `$this->app->singleton(CacheInterface::class, ...)` 또는 `app()->bind(StorageInterface::class, ...)` 는 코어/타 확장 도메인을 누수시킨다. audit 룰 `extension-no-global-cache-rebind` 가 자동 차단한다.
- **글로벌 `CacheInterface` / `StorageInterface` 바인딩 재정의 금지** — `$this->app->singleton(CacheInterface::class, ...)` 또는 `app()->bind(StorageInterface::class, ...)` 는 코어/타 확장 도메인을 누수시킨다. 정적 검사가 자동 차단한다.
- **인라인 contextual binding 복제 금지** — `$this->app->when([X::class])->needs(StorageInterface::class)->give(...)` 패턴을 직접 작성하지 말고 `$storageServices` 배열에 등록한다.
- **Listener 의 동적 인스턴스 생성에서 `app(CacheInterface::class)` 직접 호출 금지** — `app()->makeWith(MyService::class, [...])` 로 컨테이너 해석에 위임해야 contextual binding 이 적용된다.
- **HTTP Kernel 미들웨어 그룹 직접 조작 금지** — SP `boot()` 에서 `HttpKernel::append/prepend/pushMiddlewareToGroup()` / `aliasMiddleware()`, 또는 라우트 파일에서 자기 `Http\Middleware\` FQCN 을 `->middleware(Foo::class)` 로 부착하지 않는다. 대신 `Plugin::getMiddleware()` 로 미들웨어와 부착 대상(targets)을 선언하면 코어 self-gate 게이트가 요청 시점에 실행한다. audit 룰 `extension-middleware-declarative-registration` / `extension-route-middleware-alias-reference` 가 자동 차단한다. 상세: [docs/backend/middleware.md "확장 미들웨어 선언 (self-gate)"](../backend/middleware.md#확장-미들웨어-선언-self-gate).
- **HTTP Kernel 미들웨어 그룹 직접 조작 금지** — SP `boot()` 에서 `HttpKernel::append/prepend/pushMiddlewareToGroup()` / `aliasMiddleware()`, 또는 라우트 파일에서 자기 `Http\Middleware\` FQCN 을 `->middleware(Foo::class)` 로 부착하지 않는다. 대신 `Plugin::getMiddleware()` 로 미들웨어와 부착 대상(targets)을 선언하면 코어 self-gate 게이트가 요청 시점에 실행한다. 정적 검사가 자동 차단한다. 상세: [docs/backend/middleware.md "확장 미들웨어 선언 (self-gate)"](../backend/middleware.md#확장-미들웨어-선언-self-gate).
상세: [cache-driver.md `## 확장에서 cache 바인딩`](cache-driver.md)
+7 -7
View File
@@ -431,7 +431,7 @@ Call to undefined method App\Services\CoreUpdateService::ensureWritableDirectori
beta.4 의 `Upgrade_7_0_0_beta_4` step 이 `app(CoreUpdateService::class)->ensureWritableDirectories(...)` 를 호출했으나, 부모 메모리의 stale beta.3 `CoreUpdateService` 인스턴스에는 `ensureWritableDirectories` 가 없어 fatal. 디스크는 이미 beta.4 였음에도 PHP autoloader 가 beta.3 인스턴스를 재사용한 결과.
#### 자동 검출 — `upgrade-step-vone-safety` audit rule (manual-only)
#### 자동 검출 — V-1 안전 정적 검사 (리뷰 경고)
`upgrades/Upgrade_*.php` 안의 `app(\w+Service::class)` / `app(\w+Manager::class)` / `app(\w+Repository::class)` 패턴은 PR review reviewer 에게 manual-only 경고를 발행한다. 자동 차단은 아니지만, 매치된 위치를 보고 "이 메서드가 이전 버전 디스크에 존재했는가" 를 reviewer 가 수동 판정한다. 면제: `// audit:allow upgrade-step-vone-safety reason: ...` 인라인 주석.
@@ -650,7 +650,7 @@ $pluginResult = app(PluginManager::class)->resyncAllActiveDeclarativeArtifacts()
3. 확장 작성자는 자신의 `module.php` / `plugin.php` 에 declaration override (필요 확장만)
4. manifest(`module.json` / `plugin.json`) 변경 불필요 — declaration 은 PHP 클래스 메서드
**자동 정합성 검증**: 정적 audit 도구가 새 declaration 메서드 추가 시 sync 묶음에 누락 없이 반영되는지 검증. 누락 시 빌드/커밋 단계에서 차단된다.
**자동 정합성 검증**: 정적 검사가 새 declaration 메서드 추가 시 sync 묶음에 누락 없이 반영되는지 검증. 누락 시 빌드/커밋 단계에서 차단된다.
### 권한 정상화 실패 노출
@@ -670,7 +670,7 @@ spawn 자식 (경로 B) 은 디스크의 *최신* 코드/시더/카탈로그를
- **코어**: 7.0.0-beta.5 부터 신규 step 의무 (beta.2~4 는 legacy 호환 유지)
- **번들 모듈/플러그인**: `module.json` / `plugin.json` 의 `g7_version` 제약 최소 버전이 `7.0.0-beta.5` 이상이면 *그 확장의 현재 version 부터* 신규 step 의무. 그 미만이면 legacy (가드 미발동)
- **외부 확장 (`modules/{not _bundled}` / `plugins/{not _bundled}`)**: 런타임 가드는 동일하게 발화 (manifest g7_version 판정), audit 만 적용 제외 (사용자 수정 코드 PR 차단 부적합)
- **외부 확장 (`modules/{not _bundled}` / `plugins/{not _bundled}`)**: 런타임 가드는 동일하게 발화 (manifest g7_version 판정), 정적 검사만 적용 제외 (사용자 수정 코드 PR 차단 부적합)
- **번들 템플릿/언어팩**: upgrade step 시스템 자체가 부재 — 미래 도입 시 동일 규약 자동 상속
### 확장 작성자의 적용 트리거
@@ -785,10 +785,10 @@ final public function run(UpgradeContext $context): void
- **raw JSON 만 read** (Applier) — 시더 클래스 `Database\Seeders\*` 참조 금지 (fresh-load invariant)
- **idempotent** — `Schema::hasColumn` / `where->exists()` 가드 동반
- **V-1 안전 강화** — `app(*Service|*Manager|*Repository::class)` 호출 금지 (audit `upgrade-step-data-snapshot` 가 error 로 차단)
- **V-1 안전 강화** — `app(*Service|*Manager|*Repository::class)` 호출 금지 (정적 검사가 차단)
- **버전 격리** — 다른 버전 namespace `App\Upgrades\Data\V{other}\*` 참조 금지
- **로컬 헬퍼 + Illuminate 파사드만 사용** — 신규 도입 클래스 / 미래 변경 가능 클래스 의존 회피
- **공용 헬퍼 사용 시 신중** — `FilePermissionHelper::copyDirectory` 처럼 V-1 안전 검증된 헬퍼만 허용 (audit 룰이 미허용 호출은 차단)
- **공용 헬퍼 사용 시 신중** — `FilePermissionHelper::copyDirectory` 처럼 V-1 안전 검증된 헬퍼만 허용 (정적 검사가 미허용 호출은 차단)
### 강제 메커니즘
@@ -797,7 +797,7 @@ final public function run(UpgradeContext $context): void
| 런타임 | 코어 | `CoreUpdateService::runUpgradeSteps()` 의 instance 검증 | 버전 ≥ beta.5 인데 `AbstractUpgradeStep` 미상속 → `CoreUpdateOperationException` throw → update 전체 중단 → 백업 복원 |
| 런타임 | 모듈 | `ModuleManager::runUpgradeSteps()` 내 `ExtensionUpgradeGuardHelper` 호출 | manifest g7_version 기반 since-version 판정 → 미상속 시 `RuntimeException` throw |
| 런타임 | 플러그인 | `PluginManager::runUpgradeSteps()` 내 `ExtensionUpgradeGuardHelper` 호출 | 동일 패턴 (식별자만 다름) |
| PR 시점 | 코어 + 번들 모듈/플러그인 | audit rule `upgrade-step-data-snapshot` (severity: error) | namespace 불일치 / Seeders use / app() 호출 / 다른 버전 참조 / 공용 디렉토리 사용 자동 차단. 확장 경로는 manifest g7_version 기반 since-version 으로 검사 범위 결정 |
| PR 시점 | 코어 + 번들 모듈/플러그인 | 정적 검사 (위반 시 차단) | namespace 불일치 / Seeders use / app() 호출 / 다른 버전 참조 / 공용 디렉토리 사용 자동 차단. 확장 경로는 manifest g7_version 기반 since-version 으로 검사 범위 결정 |
면제: `// audit:allow upgrade-step-data-snapshot reason: ...` 인라인 주석. legacy 미들 케이스 또는 임시 우회 시.
@@ -831,7 +831,7 @@ final public function run(UpgradeContext $context): void
4. `04_IdentityPermissionPivotMerge.php` — IDV 권한 rename 충돌 경로 피벗 병합
5. `05_RecoverPublicStorageSymlink.php` — public/storage symlink 복구
beta.4 까지 출시본의 박제된 핫픽스 모든 동작이 본 격리 구조로 100% 보존되며, 미래 버전이 이 디렉토리를 *수정하지 않는 한* (수정은 audit error) beta.1 → beta.7 같은 멀티 점프에서도 동일한 결과를 보장한다.
beta.4 까지 출시본의 박제된 핫픽스 모든 동작이 본 격리 구조로 100% 보존되며, 미래 버전이 이 디렉토리를 *수정하지 않는 한* (수정은 정적 검사 차단 대상) beta.1 → beta.7 같은 멀티 점프에서도 동일한 결과를 보장한다.
---
+60 -1
View File
@@ -33,7 +33,7 @@
| 필드 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `path` | string | - | 이동할 경로 |
| `path` | string | - | 이동할 경로. 목적지는 이 필드(없으면 액션 노드의 `target`)로만 정해진다 — `url` / `href` / `to` 같은 다른 이름으로 적으면 읽히지 않아 목적지가 `undefined` 가 되고, 예외도 404 도 없이 버튼만 동작하지 않는다 |
| `query` | object | - | 쿼리 파라미터 객체 |
| `mergeQuery` | boolean | false | 기존 쿼리 파라미터와 병합 |
| `replace` | boolean | false | URL만 변경 (페이지 리로드 없음) |
@@ -169,6 +169,65 @@
}
```
`params` 형태별 결과는 다음과 같다. `replaceUrl` 도 동일하다.
| `params` 형태 | 결과 |
| --- | --- |
| `mergeQuery: true` + `query: {}` | 현재 URL 쿼리 **전부 유지** |
| `mergeQuery: true` + `query: {k: v}` | 유지 + `k` 만 덮어씀 (빈 문자열이면 `k` 제거) |
| `mergeQuery: true` + `query` 키 없음 | 전부 유지 (engine-v1.54.2+. 그 이전에는 병합이 no-op 이 되어 **전부 소실**) |
| `query: {k: v}` (mergeQuery 없음) | `k` 외 **전부 소실** |
| `query` 키 자체가 없음 | 쿼리 **전부 소실** |
### 목록 컨텍스트 왕복 규약
페이지네이션 목록 화면과 그에 딸린 **상세 · 형제 상세(이전글/다음글) · 작성/수정 폼 · 확인 모달**은 하나의 목록 클러스터다. 이 클러스터 안에서의 이동은 URL 목록 상태(`page`/`search`/`category`/`filters[*]`/정렬/`per_page`)를 손실 없이 보존해야 한다.
```json
"params": { "path": "…", "mergeQuery": true, "query": {} }
```
지켜야 할 점:
- **모든 leg 에 동일 적용한다.** 목록 진입 / 목록 복귀 / 형제 상세 이동 / 폼 취소 / 삭제 후 복귀 중 하나만 빠져도 그 지점에서 상태가 끊기고, 이후 '목록' 버튼은 복원할 상태가 없어 1페이지로 되돌아간다. 공개 이슈 #75 가 정확히 이 형태였다 — 행 클릭과 '목록' 버튼에는 규약이 적용됐지만 이전글/다음글만 누락됐다.
- **현재 값을 그대로 다시 넘기는 키는 열거하지 않는다.** `{"del": "{{query.del ?? ''}}"}` 같은 열거는 `mergeQuery` 가 이미 나르므로 중복이고, 나머지 키를 빠뜨리게 만드는 원인이 된다. 값을 **바꿔야** 하는 키만 남긴다.
- **페이지를 되돌려야 하면 `{"page": ""}`** 를 쓴다(빈 문자열 = 파라미터 제거 = 1페이지). 새 검색을 실행하는 버튼이 여기 해당한다.
- **새로고침 버튼도 병합 대상이다.** 새로고침은 보던 목록을 다시 부르는 동작이지 조건을 지우는 동작이 아니다.
- **`mergeQuery` 는 boolean 리터럴로 고정한다.** `"{{cond}}"` 처럼 분기시키면 한쪽 분기에서만 상태가 조용히 사라진다.
- **`path` 에 인라인 쿼리스트링을 붙이지 않는다.** `buildMergedQueryPath` 가 `?` 이후를 잘라내므로 `"/board/{{slug}}/write?parent_id={{id}}"` 의 `parent_id` 는 병합 시 사라진다. `query` 객체로 옮긴다.
- **목적지가 표현식이면 의도를 명시한다.** `"{{route.id ? '/board/' + route.slug + '/' + route.id : '/board/' + route.slug}}"` 같은 취소 버튼은 문자열 리터럴로 목적지가 드러나므로 룰이 클러스터 소속을 판정한다. 그러나 `"{{_global.shopBase}}/products"` / `"{{notification.url}}"` 처럼 경로 전체가 런타임 값이면 정적으로는 알 수 없다. 클러스터 내 이동이면 `mergeQuery: true` 를, 밖으로 나가는 이동이면 아래 면제 주석을 남긴다.
- **`path` 를 생략한 액션도 병합 대상이다.** `path` 없이 `query` 만 주면 "현재 주소를 유지한 채 쿼리만 바꾼다" 는 뜻이라 목록 화면 자신에게 작용한다. 그런데 `mergeQuery` 가 없으면 위 표의 마지막 두 행대로 쿼리가 통째로 대체되므로, 상세 화면의 탭 전환(`{"tab": "reviews"}`)이나 좌우 분할 목록의 항목 선택(`{"id": …, "mode": "view"}`) 한 번에 방금 건 검색어·정렬·페이지가 사라진다. 목적지 문자열이 없어 경로 기반 판정이 닿지 않는 자리라 눈에 잘 띄지 않는다.
- **병합은 값을 나르지 않는다.** `mergeQuery` 는 "지금 URL 에 있는 값을 이어받는다" 는 뜻이지 "새 값을 만든다" 가 아니다. 검색 실행·페이지 이동처럼 값을 **바꾸는** 액션은 그 값을 `query` 에 직접 담아야 한다 (`{"page": "{{$args[0]}}"}`). `query: {}` 로 비우면 페이지 버튼이 아무 동작도 하지 않는다.
의도적 리셋은 예외다 — 검색 초기화 / 필터 초기화 / 탭 전환 / 프리셋 적용 / **다른 목록으로의 이동**이 여기 해당한다. 이때는 액션 노드 `comment` 에 다음을 남겨 의도를 코드에 기록한다.
리셋 자리에 `mergeQuery: true` 를 넣으면 그 버튼은 화면상 아무 일도 하지 않는다 — 조건을 비우려고 이동하는데 병합이 방금 비운 조건을 URL 에서 되돌려놓기 때문이다. 면제 주석은 선언일 뿐 사실이 아니므로, "병합하지 않는다" 고 적어 두고 `mergeQuery: true` 를 함께 두는 자기모순이 생기지 않도록 주석과 params 를 함께 확인한다.
탭 전환은 특히 실수하기 쉽다. `/admin/templates/user` 와 `/admin/templates/admin` 처럼 탭마다 라우트가 갈리는 화면은 탭 각각이 자기 검색어·페이지를 갖는 **별개의 목록**이다. 여기에 병합을 붙이면 `?search=…&page=3` 상태에서 다른 탭으로 넘어갈 때 남의 검색어로 걸러진 3페이지(대개 빈 화면)가 열린다. 마찬가지로 프리셋 적용은 조건 세트를 통째로 갈아끼우는 동작이므로, 병합하면 프리셋이 열거하지 않은 직전 조건이 화면에 보이지 않는 채 URL 에 남는다.
앞선 액션이 `setState` 로 로컬 필터를 비웠다면 **URL 쪽 같은 키도 함께 비워야 한다**. `mergeQuery` 는 그 키를 URL 에서 그대로 이어받으므로, 비우지 않으면 입력창은 비어 보이는데 조회는 옛 조건으로 걸리는 어긋남이 생긴다 (`{"search_keyword": ""}` — 빈 문자열 = 파라미터 제거).
```json
{
"comment": "audit:allow layout-list-context-navigate-merge-query 검색 초기화 — 조건을 전부 비우는 것이 목적",
"handler": "navigate",
"params": { "path": "/board/{{route.slug}}", "mergeQuery": false }
}
```
자동 차단: 정적 검사 대상 (위반 시 차단). 목록 라우트는 저장소에서 자동 도출하므로 화이트리스트를 관리할 필요가 없다. 도출 근거는 두 가지다.
1. `query` 에 `page` 키가 등장하는 navigate 목적지
2. 화면이 실제로 페이지네이션 UI(`onPageChange` 액션 / `Pagination` 컴포넌트)를 갖고 있는지 — partial 을 따라 전파한다
3. 데이터소스 params 가 URL 에서 페이지를 읽는지 (`"page": "{{query.page}}"`) — 페이저를 같은 파일에 그리지 않는 목록까지 포함
4. 데이터소스 params 가 URL 에서 목록 필터를 읽는지 (`search`/`category`/`sort`/`status`/`filters`) — **페이지가 URL 이 아니라 클라이언트 상태(무한스크롤)로 관리되는 목록**도 필터는 URL 에 있으므로 그 목록의 새로고침·왕복이 필터를 보존해야 한다
두 번째 근거가 없으면 "이미 규약을 적용한 화면일수록 감시 대상이 된다" 는 순환이 생겨, 아직 고치지 않은 화면이 오히려 탐지에서 빠진다. 네 번째 근거가 없으면 무한스크롤 목록의 검색어가 새로고침에서 조용히 사라지는 결함이 빠진다.
정적 검사는 반대 방향도 본다 — 탭 전환(`onTabChange`)에 `mergeQuery: true` 를 붙이거나, 자기 클러스터와 상하 관계가 전혀 없는 다른 목록 라우트로 나가면서 병합하면 위반으로 잡는다. "병합 누락"만 보면 이 방향이 통째로 사각지대가 된다.
한계 — 정적 검사는 레이아웃 JSON 만 스캔한다. navigate leg 이 컴포넌트 코드(`.tsx`)에 있으면(예: `ProductCard` 의 카드 클릭) 룰의 시야 밖이다. 목록 항목으로 렌더되는 컴포넌트가 상세로 이동할 때는 컴포넌트가 직접 `mergeQuery` 를 실어야 한다(`ProductCard` 의 `preserveListQuery` prop 참조).
### 배열 쿼리 파라미터
> **버전**: engine-v1.10.0+
+1 -1
View File
@@ -175,7 +175,7 @@ export const Foo = ({ /* 도메인 prop */, editorAttrs }: FooProps) => (
- 사용자 페이지(비편집)에서는 `editorAttrs` 미주입 → `{...editorAttrs}` 가 no-op → DOM 구조/속성 불변 (프리뷰 ↔ 사용자 페이지 패리티 유지).
- `editorAttrs` 만 spread 하므로 도메인 prop 누출/HTML 동명 prop 타입 충돌이 없다. (`React.HTMLAttributes` 상속 + `{...rest}` 방식은 도메인 prop 누출 위험으로 채택 안 함.)
- 자동 검출: audit 룰 `editor-attrs-passthrough` (nesting.draggable 의 composite/layout 컴포넌트가 `editorAttrs` 미수신 시 error). 의도적 면제는 파일 헤더 `// editor-attrs:allow <사유>`.
- 자동 검출: 정적 검사가 nesting.draggable 의 composite/layout 컴포넌트가 `editorAttrs` 를 받지 않으면 차단한다. 의도적 면제는 파일 헤더 `// editor-attrs:allow <사유>`.
### 요소 id 패스스루 (코어 일괄 ID)
+1 -1
View File
@@ -142,7 +142,7 @@ import { Icon, IconName } from '../basic/Icon';
### 레이아웃 편집기 capability 선언 의무
새 draggable 컴포넌트(레이아웃 편집기 팔레트/캔버스에 노출)는 위 4개 파일에 더해 호스트 템플릿 `editor-spec/componentCapabilities.json` 에 capability 를 선언해야 편집기에서 "편집 불가(no-editable)"가 되지 않는다. 표시 텍스트/아이콘/select/목록·배열·표 데이터 prop 별로 `propControls`(속성 탭) / `dataProps`(데이터 연결) / `nodeEditor`·`canvasOverlay`(구조 에디터) / `styleControls`(스타일 탭) / `events`(동작 탭) 를 선언한다. 데이터 표면을 가진 컴포넌트가 편집 슬롯도 비대상 allowlist 도 없으면 audit `editor-all-draggable-data-editable` 가 차단한다. composite/layout 컴포넌트는 `editorAttrs` spread + `id` 패스스루도 필요하다.
새 draggable 컴포넌트(레이아웃 편집기 팔레트/캔버스에 노출)는 위 4개 파일에 더해 호스트 템플릿 `editor-spec/componentCapabilities.json` 에 capability 를 선언해야 편집기에서 "편집 불가(no-editable)"가 되지 않는다. 표시 텍스트/아이콘/select/목록·배열·표 데이터 prop 별로 `propControls`(속성 탭) / `dataProps`(데이터 연결) / `nodeEditor`·`canvasOverlay`(구조 에디터) / `styleControls`(스타일 탭) / `events`(동작 탭) 를 선언한다. 데이터 표면을 가진 컴포넌트가 편집 슬롯도 비대상 allowlist 도 없으면 정적 검사가 차단한다. composite/layout 컴포넌트는 `editorAttrs` spread + `id` 패스스루도 필요하다.
상세: [editor-spec.md "componentCapabilities"](../extension/editor-spec.md) · [components-types.md "편집기 attribute 패스스루"](components-types.md)
+1 -1
View File
@@ -84,7 +84,7 @@
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|------|------|------|--------|------|
| `id` | string | ✅ | - | 데이터 소스 고유 ID (컴포넌트에서 `{{id.data}}` 형태로 참조) |
| `label_key` | string | ❌ | - | 친화 명칭 `$t:` 다국어 키 (예 `$t:editor.data_source.products`). 레이아웃 편집기 데이터 연결 검색 피커가 현재 로케일 명칭으로 표시. 미지정 시 `id` 폴백. 번들 템플릿은 전 data_source 보유 의무(audit `data-source-label-key-coverage`) |
| `label_key` | string | ❌ | - | 친화 명칭 `$t:` 다국어 키 (예 `$t:editor.data_source.products`). 레이아웃 편집기 데이터 연결 검색 피커가 현재 로케일 명칭으로 표시. 미지정 시 `id` 폴백. 번들 템플릿은 전 data_source 보유 의무(정적 검사) |
| `type` | string | ✅ | - | 데이터 소스 타입 (`api`, `static`, `route_params`, `query_params`, `websocket`) |
| `endpoint` | string | ✅* | - | API 엔드포인트 (type이 `api`일 때 필수) |
| `method` | string | ❌ | `GET` | HTTP 메서드 (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`) |
+1 -1
View File
@@ -339,7 +339,7 @@ public function test_pg_payment_member_with_mileage_to_jeju(): void { ... }
TypeScript 테스트는 `// @scenario` / `// @effects` 주석 동일 사용.
audit rule `test-scenario-coverage` 가 매니페스트 cross product 와 effects 항목을 테스트 docblock 마킹과 대조하여 누락을 검출 (자동 차단).
정적 검사가 매니페스트 cross product 와 effects 항목을 테스트 docblock 마킹과 대조하여 누락을 검출한다 (자동 차단).
### cross product 폭발 관리
+1 -1
View File
@@ -230,7 +230,7 @@ test('unauthenticated_401 × no_token', async ({ page }) => {
});
```
`audit-branch.cjs` 의 `test-scenario-coverage` 룰이 매니페스트 axes ↔ docblock 매칭을 자동 검증.
정적 검사가 매니페스트 axes ↔ docblock 매칭을 자동 검증.
## §8. 회귀 테스트 4단계
@@ -6,6 +6,10 @@
## [0.1.0] - 2026-07-01
### Fixed
- 메모 목록에서 작성·수정 화면에 다녀오면 보고 있던 페이지가 사라지고 1페이지로 되돌아가던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
### Changed
- 메모 관리 화면의 콘텐츠 카드 외형을 sirsoft-admin_basic 표준 시맨틱 (.admin-card) 과 정합 — 다른 관리자 화면과 같은 결로 통일.
@@ -142,7 +142,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/memos"
"path": "/admin/memos",
"mergeQuery": true,
"query": {}
}
}
],
@@ -183,7 +185,9 @@
{
"handler": "navigate",
"params": {
"path": "/admin/memos"
"path": "/admin/memos",
"mergeQuery": true,
"query": {}
}
}
],
@@ -75,7 +75,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/memos/create"
"path": "/admin/memos/create",
"mergeQuery": true,
"query": {}
}
}
],
@@ -18,6 +18,8 @@
### Fixed
- 관리자 게시글 목록에서 글을 열어 이전글·다음글로 넘어가거나 수정 후 돌아올 때 보고 있던 페이지·검색어·분류가 사라지던 문제를 수정했습니다. 목록 ↔ 상세 ↔ 이전/다음 글 ↔ 작성·수정 폼 어디로 오가든 목록 상태가 유지되며, 신고 관리와 게시판 목록도 동일하게 개선됐습니다. 새로고침 버튼도 걸어 둔 검색·필터를 지운 채 새로 부르지 않고 보고 있던 목록을 그대로 다시 부릅니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 게시글 목록에서 뒤쪽 페이지를 보던 중 검색하거나 게시 상태를 바꾸면 그 페이지 번호가 그대로 유지돼 결과가 없는 빈 화면이 열리던 문제를 수정했습니다. 검색과 상태 변경은 1페이지부터 보여주며, 사용자가 고른 표시 개수와 정렬은 그대로 유지됩니다. 검색창에서 엔터를 치는 경우와 검색 버튼을 누르는 경우가 서로 다르게 동작하던 것도 함께 맞췄습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 첨부파일을 사용하지 않는 게시판을 아무것도 바꾸지 않고 저장해도 "허용 파일 확장자를 최소 1개 이상 입력하세요" 오류가 나며 저장이 막히던 문제를 수정했습니다. 첨부를 쓰지 않는 게시판에서는 허용 확장자를 비워 두어도 정상적으로 저장됩니다. 게시판 환경설정의 기본값도 동일하게 동작합니다. (#78 @jiwonpapa 님께서 제보해주셨습니다.)
- 새 글 표시 기간을 "0시간"(새 글 표시 안 함)으로 저장할 수 없던 문제를 수정했습니다. 0은 원래 새 글 배지를 끄는 값으로 동작하도록 되어 있었으나 저장 단계에서 막혀 있었습니다.
- 게시판 환경설정에서 제목·댓글 최소 길이를 0(제한 없음)으로 저장할 수 없던 문제를 수정했습니다. 내용 최소 길이는 0이 허용되어 같은 화면에서 항목마다 동작이 달랐습니다.
@@ -0,0 +1,347 @@
/**
* @file admin-list-context-round-trip.test.ts
* @description 관리자 게시판 목록 컨텍스트 왕복 시 URL 목록 상태 보존 회귀 (#75)
*
* 결함: 목록(`/admin/board/{slug}`) 에서 상세로 들어간 뒤 이전글/다음글/폼취소/삭제후복귀를
* 거치면 navigate 가 `mergeQuery` 없이 실행되어 page/search/category/filters 가 URL 에서
* 사라졌다. 그 뒤 '목록' 버튼은 복원할 상태가 없어 1페이지로 되돌아간다.
*
* 검증 방식 2단:
* 1. 실 레이아웃 JSON 전수 분석 — 클러스터 내 navigate 가 모두 `mergeQuery: true` 인지
* 2. 실 액션을 엔진에 태워 실행(`triggerAction`) — 선언이 실제 목적지 URL 로 이어지는지
* 손으로 쓴 조각이 아니라 실 레이아웃 JSON 을 읽는다. 실파일이 규약을 어기면 red 여야 한다.
* 선언만 검사하면 "JSON 에 리터럴이 적혀 있다" 까지만 고정되고 병합 결과는 미검증으로 남는다.
*
* @scenario leg=row_click|prev|next|form_cancel|delete_return
* @effects navigation_preserves_list_query
*
* @vitest-environment jsdom
*/
import { describe, it, expect, afterEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import {
createLayoutTest,
createMockComponentRegistryWithBasics,
} from '@core/template-engine/__tests__/utils/layoutTestUtils';
const LAYOUTS_ROOT = path.resolve(__dirname, '../../../layouts/admin');
/**
* 레이아웃 파일 → 그 화면이 속한 목록 클러스터.
*
* 클러스터는 파일 단위로 다르다. 예를 들어 게시판 목록(`/admin/boards`)에서 특정 게시판의
* 글 목록(`/admin/board/{slug}`)으로 가는 것은 왕복이 아니라 **다른 목록으로의 진입**이므로
* 병합 대상이 아니다. 확장 전체에 하나의 목록 목록을 적용하면 이런 이동까지 위반으로 잡힌다.
*/
const CLUSTER_FILES: Array<[string, string]> = [
['admin_board_posts_index.json', '/admin/board/*'],
['admin_board_post_detail.json', '/admin/board/*'],
['admin_board_post_form.json', '/admin/board/*'],
['partials/admin_board_post_detail/_post_card_content.json', '/admin/board/*'],
['partials/admin_board_post_detail/_reply_card_content.json', '/admin/board/*'],
['admin_board_index.json', '/admin/boards'],
['admin_board_form.json', '/admin/boards'],
['admin_board_reports_index.json', '/admin/boards/reports'],
['admin_board_reports_detail.json', '/admin/boards/reports'],
['partials/admin_board_reports_detail/_card_report_info.json', '/admin/boards/reports'],
];
type NavAction = {
handler: string;
comment?: string;
params: { path?: string; mergeQuery?: unknown; query?: unknown };
};
/**
* JSON 트리에서 navigate/replaceUrl 액션을 모두 수집합니다.
*
* @param node 순회 시작 노드
* @returns 액션 목록
*/
function collectNavActions(node: unknown): NavAction[] {
const out: NavAction[] = [];
const walk = (n: unknown): void => {
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
if (!n || typeof n !== 'object') return;
const obj = n as Record<string, unknown>;
if (
(obj.handler === 'navigate' || obj.handler === 'replaceUrl') &&
obj.params &&
typeof obj.params === 'object'
) {
out.push(obj as unknown as NavAction);
}
Object.values(obj).forEach(walk);
};
walk(node);
return out;
}
/**
* 목적지 경로를 비교용 표준형(`{{expr}}` → `*`)으로 바꿉니다.
*
* @param raw 원본 경로
* @returns 표준형 경로 (판정 불가 시 null)
*/
function normalizePath(raw: unknown): string | null {
if (typeof raw !== 'string' || raw === '') return null;
const p = raw.replace(/\{\{[^}]*\}\}/g, '*').split('?')[0].replace(/\/+$/, '');
return p.startsWith('/') ? p : null;
}
/**
* 경로가 목록 라우트와 같은 클러스터인지 판정합니다.
*
* @param dest 표준형 목적지
* @param list 표준형 목록 라우트
* @returns 같은 클러스터 여부
*/
function inFamily(dest: string, list: string): boolean {
return dest === list || dest.startsWith(`${list}/`);
}
/**
* 레이아웃 파일을 읽어 파싱합니다.
*
* @param rel 레이아웃 루트 기준 상대 경로
* @returns 파싱된 JSON
*/
function readLayout(rel: string): unknown {
return JSON.parse(fs.readFileSync(path.join(LAYOUTS_ROOT, rel), 'utf8'));
}
describe('#75 관리자 게시판 목록 컨텍스트 왕복 — mergeQuery 전수', () => {
it.each(CLUSTER_FILES)('%s (클러스터 %s) 내 navigate 는 모두 mergeQuery: true', (rel, list) => {
const offenders = collectNavActions(readLayout(rel))
.filter((a) => {
if (typeof a.comment === 'string' && a.comment.includes('audit:allow')) return false;
const dest = normalizePath(a.params.path);
if (!dest) return a.params.mergeQuery !== undefined && a.params.mergeQuery !== true;
if (!inFamily(dest, list)) return false;
return a.params.mergeQuery !== true;
})
.map((a) => `${a.params.path} (mergeQuery=${JSON.stringify(a.params.mergeQuery)})`);
expect(offenders, `목록 상태를 떨구는 navigate: \n ${offenders.join('\n ')}`).toEqual([]);
});
});
describe('#75 개별 leg 고정', () => {
/**
* 특정 목적지로 가는 첫 navigate 액션을 찾습니다.
*
* @param rel 레이아웃 상대 경로
* @param destPath 목적지 경로(원문 그대로)
* @returns 액션
*/
const leg = (rel: string, destPath: string): NavAction => {
const hit = collectNavActions(readLayout(rel)).find((a) => a.params.path === destPath);
if (!hit) throw new Error(`${rel} 에서 "${destPath}" navigate 를 찾지 못했습니다.`);
return hit;
};
it('상세 이전글 leg 이 목록 상태를 승계한다', () => {
const a = leg(
'partials/admin_board_post_detail/_post_card_content.json',
'/admin/board/{{route.slug}}/post/{{post?.data?.navigation?.prev?.id}}'
);
expect(a.params.mergeQuery).toBe(true);
});
it('상세 다음글 leg 이 목록 상태를 승계한다', () => {
const a = leg(
'partials/admin_board_post_detail/_post_card_content.json',
'/admin/board/{{route.slug}}/post/{{post?.data?.navigation?.next?.id}}'
);
expect(a.params.mergeQuery).toBe(true);
});
it("상세 '목록' 버튼이 목록 상태를 복원한다", () => {
const a = leg('admin_board_post_detail.json', '/admin/board/{{route.slug}}');
expect(a.params.mergeQuery).toBe(true);
});
it('목록 행 클릭이 목록 상태를 상세로 나른다', () => {
const a = leg('admin_board_posts_index.json', '/admin/board/{{row.slug}}/post/{{row.id}}');
expect(a.params.mergeQuery).toBe(true);
});
it('삭제 확인 후 복귀 navigate 의 mergeQuery 가 분기 표현식이 아니다', () => {
const dynamic = collectNavActions(readLayout('admin_board_post_detail.json')).filter(
(a) => 'mergeQuery' in a.params && typeof a.params.mergeQuery !== 'boolean'
);
expect(dynamic.map((a) => a.params.path)).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// 실행 검증 — 실 액션을 엔진에 태워 목적지 URL 을 확인한다
// ---------------------------------------------------------------------------
/**
* 실 JSON 액션을 현재 URL 상태에서 실행하고 목적지를 돌려줍니다.
*
* @param action 실 레이아웃 JSON 에서 뽑은 navigate 액션
* @returns navigate 목적지 경로
*/
async function runNav(action: NavAction): Promise<string> {
const utils = createLayoutTest(
{
version: '1.0.0',
layout_name: 'nav_action_probe',
data_sources: [],
components: [{ type: 'basic', name: 'Div', props: { 'data-testid': 'probe' } }],
} as never,
{ componentRegistry: createMockComponentRegistryWithBasics() as never, locale: 'ko' }
);
await utils.render();
await utils.triggerAction({ type: 'click', ...action } as never);
const dest = utils.getNavigationHistory()[0];
utils.cleanup();
return dest;
}
/**
* 목적지 URL 의 쿼리스트링을 파싱합니다.
*
* @param dest navigate 목적지
* @returns URLSearchParams
*/
function queryOf(dest: string): URLSearchParams {
return new URLSearchParams(dest.includes('?') ? dest.slice(dest.indexOf('?')) : '');
}
/**
* 특정 목적지로 가는 첫 navigate 액션을 찾습니다.
*
* @param rel 레이아웃 상대 경로
* @param destPath 목적지 경로(원문 그대로)
* @returns 액션
*/
function navLeg(rel: string, destPath: string): NavAction {
const hit = collectNavActions(readLayout(rel)).find((a) => a.params.path === destPath);
if (!hit) throw new Error(`${rel} 에서 "${destPath}" navigate 를 찾지 못했습니다.`);
return hit;
}
describe('#75 관리자 게시판 목록 컨텍스트 왕복 — 실행 결과 URL', () => {
afterEach(() => {
window.history.replaceState({}, '', '/');
});
const legs: Array<[string, string]> = [
['이전글', '/admin/board/{{route.slug}}/post/{{post?.data?.navigation?.prev?.id}}'],
['다음글', '/admin/board/{{route.slug}}/post/{{post?.data?.navigation?.next?.id}}'],
];
it.each(legs)('%s leg 실행 시 page/search/category 가 모두 살아남는다', async (_label, destPath) => {
window.history.replaceState({}, '', '/admin/board/free/post/45?page=2&search=테스트&category=공지');
const q = queryOf(await runNav(navLeg('partials/admin_board_post_detail/_post_card_content.json', destPath)));
expect(q.get('page')).toBe('2');
expect(q.get('search')).toBe('테스트');
expect(q.get('category')).toBe('공지');
});
it("상세 '목록' 버튼 실행 시 목록 상태가 그대로 복귀된다", async () => {
window.history.replaceState({}, '', '/admin/board/free/post/45?page=2&search=테스트');
const q = queryOf(await runNav(navLeg('admin_board_post_detail.json', '/admin/board/{{route.slug}}')));
expect(q.get('page')).toBe('2');
expect(q.get('search')).toBe('테스트');
});
it('검색 실행(Enter) 은 page 를 떨구고 표시 개수는 유지한다', async () => {
window.history.replaceState({}, '', '/admin/board/free?page=5&per_page=30&sort_by=created_at');
const action = collectNavActions(readLayout('admin_board_posts_index.json')).find(
(a) => (a as unknown as { type?: string }).type === 'keypress'
);
expect(action, 'admin_board_posts_index.json 에 검색 Enter navigate 가 없습니다.').toBeTruthy();
const q = queryOf(await runNav(action as NavAction));
// page 는 새 검색이므로 1페이지로 되돌린다(파라미터 제거). 되돌리지 않으면 결과의 5페이지로 가 빈 화면이 열린다.
expect(q.get('page')).toBeNull();
expect(q.get('per_page')).toBe('30');
expect(q.get('sort_by')).toBe('created_at');
});
it('이전글 → 목록 순서로 이어가도 목록 페이지가 복원된다', async () => {
window.history.replaceState({}, '', '/admin/board/free/post/45?page=2&search=테스트');
// ① 이전글로 이동 → 이동 결과를 실제 URL 로 반영 (다음 leg 의 mergeQuery 입력)
const afterPrev = await runNav(
navLeg(
'partials/admin_board_post_detail/_post_card_content.json',
'/admin/board/{{route.slug}}/post/{{post?.data?.navigation?.prev?.id}}'
)
);
window.history.replaceState({}, '', afterPrev);
// ② 그 상태에서 '목록' 복귀
const q = queryOf(await runNav(navLeg('admin_board_post_detail.json', '/admin/board/{{route.slug}}')));
expect(q.get('page')).toBe('2');
expect(q.get('search')).toBe('테스트');
});
});
// ---------------------------------------------------------------------------
// 검색·필터 변경 시 page 되돌림 (#75)
//
// mergeQuery 는 현재 페이지까지 함께 나른다. 그래서 목록을 좁히는 조건(검색어·카테고리·상태)을
// 바꾸는 액션은 page 를 명시적으로 되돌려야 한다. 되돌리지 않으면 5페이지를 보던 중 검색했을 때
// 결과의 5페이지로 이동해 빈 화면이 열린다. 한 화면 안에서 진입점마다 갈리면(버튼은 되돌리고
// Enter 는 안 되돌림) 같은 기능이 다르게 동작한다.
// ---------------------------------------------------------------------------
/** 목록을 좁히는 조건 키 — 바뀌면 결과 건수가 달라지므로 page 를 되돌려야 한다. */
const NARROWING_KEY = /^(filters\[\d+\]\[value\]|search|search_keyword|category|status|target_status|type)$/;
/** page 되돌림으로 인정하는 값 — `""`(파라미터 제거) 또는 1. */
function isPageReset(value: unknown): boolean {
return value === '' || value === 1 || value === '1';
}
describe('#75 검색·필터 액션은 page 를 되돌린다 — 전수', () => {
it.each([['admin_board_posts_index.json'], ['admin_board_reports_index.json']])(
'%s 의 조건 변경 navigate 는 모두 page 를 되돌린다',
(rel) => {
const offenders = collectNavActions(readLayout(rel))
.filter((a) => {
const q = a.params.query;
if (!q || typeof q !== 'object' || Array.isArray(q)) return false;
const keys = Object.keys(q as Record<string, unknown>);
if (!keys.some((k) => NARROWING_KEY.test(k))) return false;
return !isPageReset((q as Record<string, unknown>).page);
})
.map((a) => `${a.params.path} → ${JSON.stringify(a.params.query)}`);
expect(offenders, `조건을 바꾸면서 page 를 그대로 두는 navigate: \n ${offenders.join('\n ')}`).toEqual(
[]
);
}
);
// mergeQuery 가 이미 현재 정렬을 나르므로, 검색 액션이 정렬을 다시 적어 보낼 필요가 없다.
// 열거가 남아 있으면 다음 편집자가 "여기에 적힌 값이 정렬의 근거" 로 오해한다 (기본값은 데이터소스가 정한다).
it('신고 목록 검색 액션은 정렬 키를 다시 적어 보내지 않는다', () => {
const search = (readLayout('admin_board_reports_index.json') as {
named_actions: { searchBoardReports: NavAction };
}).named_actions.searchBoardReports;
const keys = Object.keys(search.params.query as Record<string, unknown>);
expect(keys).not.toContain('sort_by');
expect(keys).not.toContain('sort_order');
});
});
@@ -325,7 +325,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards"
"path": "/admin/boards",
"mergeQuery": true,
"query": {}
}
}
]
@@ -357,7 +359,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards"
"path": "/admin/boards",
"mergeQuery": true,
"query": {}
}
}
]
@@ -69,11 +69,13 @@
},
"actions": [
{
"comment": "새로고침은 보고 있던 목록을 그대로 다시 부르는 것이다 — 페이지/검색을 유지한다 (#75).",
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards",
"mergeQuery": false
"mergeQuery": true,
"query": {}
}
}
],
@@ -102,7 +104,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards/create"
"path": "/admin/boards/create",
"mergeQuery": true,
"query": {}
}
}
],
@@ -341,8 +345,8 @@
"handler": "navigate",
"params": {
"path": "/admin/boards/{{row.slug}}/edit",
"mergeQuery": false,
"query": []
"mergeQuery": true,
"query": {}
}
},
"manage_posts": {
@@ -638,10 +642,15 @@
"target": "copy_confirm_modal"
},
{
"comment": "복사 생성 폼으로 이동 — copy_id 는 query 로 넘긴다. mergeQuery 는 path 의 인라인 쿼리스트링을 버리므로 path 에 붙여두면 사라진다 (#75).",
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards/create?copy_id={{_global.modal_data.id}}"
"path": "/admin/boards/create",
"mergeQuery": true,
"query": {
"copy_id": "{{_global.modal_data.id}}"
}
}
}
]
@@ -120,7 +120,8 @@
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}/post/{{route?.id}}",
"mergeQuery": false
"mergeQuery": true,
"query": {}
}
}
],
@@ -147,7 +148,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}"
"path": "/admin/board/{{route.slug}}",
"mergeQuery": true,
"query": {}
}
}
],
@@ -377,10 +380,12 @@
"if": "{{_global.modal_data?.type === 'comment'}}"
},
{
"comment": "삭제 후 복귀 — 게시글이면 목록으로, 답변이면 원글 상세로 돌아간다. 어느 쪽이든 목록 클러스터 안이므로 목록 상태를 유지한다. 종전에는 mergeQuery 가 분기 표현식이라 답변 삭제 경로에서만 페이지/검색이 조용히 사라졌다 (#75).",
"handler": "navigate",
"params": {
"path": "{{_global.modal_data?.type === 'post' ? '/admin/board/' + route.slug : '/admin/board/' + route.slug + '/post/' + route.id}}",
"mergeQuery": "{{_global.modal_data?.type === 'post'}}"
"mergeQuery": true,
"query": {}
},
"if": "{{_global.modal_data?.type !== 'comment'}}"
},
@@ -139,7 +139,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "{{route?.id ? '/admin/board/' + route.slug + '/post/' + route.id : '/admin/board/' + route.slug}}"
"path": "{{route?.id ? '/admin/board/' + route.slug + '/post/' + route.id : '/admin/board/' + route.slug}}",
"mergeQuery": true,
"query": {}
}
}
],
@@ -206,7 +208,9 @@
{
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}/post/{{route?.id ?? result?.data?.id}}"
"path": "/admin/board/{{route.slug}}/post/{{route?.id ?? result?.data?.id}}",
"mergeQuery": true,
"query": {}
}
}
],
@@ -430,7 +434,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "{{route?.id ? '/admin/board/' + route.slug + '/post/' + route.id : '/admin/board/' + route.slug}}"
"path": "{{route?.id ? '/admin/board/' + route.slug + '/post/' + route.id : '/admin/board/' + route.slug}}",
"mergeQuery": true,
"query": {}
}
}
],
@@ -498,7 +504,9 @@
{
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}/post/{{route?.id ?? result?.data?.id}}"
"path": "/admin/board/{{route.slug}}/post/{{route?.id ?? result?.data?.id}}",
"mergeQuery": true,
"query": {}
}
}
],
@@ -148,11 +148,13 @@
},
"actions": [
{
"comment": "새로고침은 보고 있던 목록을 그대로 다시 부르는 것이다 — 페이지/검색/필터를 유지한다 (#75).",
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}",
"mergeQuery": false
"mergeQuery": true,
"query": {}
}
}
],
@@ -180,7 +182,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}/create"
"path": "/admin/board/{{route.slug}}/create",
"mergeQuery": true,
"query": {}
}
}
],
@@ -273,11 +277,13 @@
{
"type": "keypress",
"key": "Enter",
"comment": "검색 실행 — 검색 버튼과 동일하게 page 는 1페이지로 되돌린다. 되돌리지 않으면 5페이지를 보던 중 검색 시 결과의 5페이지로 가 빈 화면이 열린다 (#75).",
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}",
"mergeQuery": true,
"query": {
"page": "",
"filters[0][field]": "{{_local.searchField || 'all'}}",
"filters[0][value]": "{{$event.target.value}}",
"filters[0][operator]": "like"
@@ -498,12 +504,14 @@
"actions": [
{
"type": "change",
"comment": "상태 필터 변경 — 카테고리 필터와 동일하게 page 를 1페이지로 되돌린다. 되돌리지 않으면 뒤쪽 페이지에서 필터를 바꿀 때 빈 화면이 열린다 (#75).",
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}",
"mergeQuery": true,
"query": {
"status": "{{$event.target.value}}"
"status": "{{$event.target.value}}",
"page": 1
}
}
}
@@ -556,6 +564,7 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 초기화 버튼 — 목록 상태를 전부 비우는 것이 이 버튼의 목적이므로 병합하지 않는다.",
"type": "click",
"handler": "navigate",
"params": {
@@ -589,11 +598,13 @@
"actions": [
{
"type": "click",
"comment": "검색 실행 — 열거한 검색 조건만 덮어쓰고 정렬/페이지당 개수 등 나머지 목록 상태는 유지한다. page 는 새 검색이므로 1페이지로 되돌린다 (#75).",
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}",
"mergeQuery": false,
"mergeQuery": true,
"query": {
"page": "",
"filters[0][field]": "{{_local.searchField || 'all'}}",
"filters[0][value]": "{{_local.searchQuery || ''}}",
"filters[0][operator]": "like",
@@ -835,7 +846,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/board/{{row.slug}}/post/{{row.id}}"
"path": "/admin/board/{{row.slug}}/post/{{row.id}}",
"mergeQuery": true,
"query": {}
}
}
]
@@ -1127,7 +1140,7 @@
"params": {
"path": "/admin/board/{{route.slug}}",
"mergeQuery": true,
"query": []
"query": {}
}
},
{
@@ -105,7 +105,8 @@
"handler": "navigate",
"params": {
"path": "/admin/boards/reports/{{route.id}}",
"mergeQuery": false
"mergeQuery": true,
"query": {}
}
}
],
@@ -132,7 +133,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards/reports"
"path": "/admin/boards/reports",
"mergeQuery": true,
"query": {}
}
}
],
@@ -338,7 +341,8 @@
"handler": "navigate",
"params": {
"path": "/admin/boards/reports/{{route.id}}",
"mergeQuery": false
"mergeQuery": true,
"query": {}
}
}
],
@@ -66,11 +66,13 @@
],
"named_actions": {
"searchBoardReports": {
"comment": "검색 실행 — 열거한 검색 조건만 덮어쓰고 페이지당 개수 등 나머지 목록 상태는 유지한다. page 는 새 검색이므로 1페이지로 되돌린다 (#75).",
"handler": "navigate",
"params": {
"path": "/admin/boards/reports",
"mergeQuery": false,
"mergeQuery": true,
"query": {
"page": "",
"filters[0][field]": "{{_local.searchField || 'all'}}",
"filters[0][value]": "{{_local.searchQuery || ''}}",
"filters[0][operator]": "like",
@@ -78,9 +80,7 @@
"type": "{{((_global.typeFilter ?? query['type[]'] ?? []) || []).length > 0 ? (_global.typeFilter ?? query['type[]']) : null}}",
"target_status": "{{((_global.targetStatusFilter ?? query['target_status[]'] ?? []) || []).length > 0 ? (_global.targetStatusFilter ?? query['target_status[]']) : null}}",
"reported_at_from": "{{_global.dateFrom !== undefined ? _global.dateFrom : (query.reported_at_from || '')}}",
"reported_at_to": "{{_global.dateTo !== undefined ? _global.dateTo : (query.reported_at_to || '')}}",
"sort_by": "{{query.sort_by || 'last_reported_at'}}",
"sort_order": "{{query.sort_order || 'desc'}}"
"reported_at_to": "{{_global.dateTo !== undefined ? _global.dateTo : (query.reported_at_to || '')}}"
}
}
}
@@ -120,11 +120,13 @@
},
"actions": [
{
"comment": "새로고침은 보고 있던 목록을 그대로 다시 부르는 것이다 — 페이지/검색/필터를 유지한다 (#75).",
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/boards/reports",
"mergeQuery": false
"mergeQuery": true,
"query": {}
}
}
],
@@ -1244,6 +1246,7 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 초기화 버튼 — 목록 상태를 전부 비우는 것이 이 버튼의 목적이므로 병합하지 않는다.",
"type": "click",
"handler": "navigate",
"params": {
@@ -2202,7 +2205,9 @@
"view": {
"handler": "navigate",
"params": {
"path": "/admin/boards/reports/{{$args[1]?.id}}"
"path": "/admin/boards/reports/{{$args[1]?.id}}",
"mergeQuery": true,
"query": {}
}
},
"go_to_board": {
@@ -347,6 +347,7 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 탭 전환 — 탭마다 다루는 대상이 달라 이전 탭의 목록 상태를 물려받으면 오히려 잘못된 화면이 된다.",
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
@@ -155,10 +155,15 @@
"if": "{{post?.data?.board?.use_reply && !post?.data?.is_notice && (post?.data?.depth ?? 0) < (post?.data?.board?.max_reply_depth ?? 1) && post?.data?.status !== 'blinded' && post?.data?.status !== 'deleted' && !post?.data?.deleted_at}}",
"actions": [
{
"comment": "답변 작성 폼으로 이동 — parent_id 는 query 로 넘긴다. mergeQuery 는 path 의 인라인 쿼리스트링을 버리므로 path 에 붙여두면 사라진다 (#75).",
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}/create?parent_id={{post?.data?.id}}"
"path": "/admin/board/{{route.slug}}/create",
"mergeQuery": true,
"query": {
"parent_id": "{{post?.data?.id}}"
}
}
}
],
@@ -261,7 +266,9 @@
"edit": {
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}/{{post?.data?.id}}/edit"
"path": "/admin/board/{{route.slug}}/{{post?.data?.id}}/edit",
"mergeQuery": true,
"query": {}
}
},
"blind": {
@@ -1166,7 +1173,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}/post/{{post?.data?.navigation?.prev?.id}}"
"path": "/admin/board/{{route.slug}}/post/{{post?.data?.navigation?.prev?.id}}",
"mergeQuery": true,
"query": {}
}
}
],
@@ -1228,7 +1237,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}/post/{{post?.data?.navigation?.next?.id}}"
"path": "/admin/board/{{route.slug}}/post/{{post?.data?.navigation?.next?.id}}",
"mergeQuery": true,
"query": {}
}
}
],
@@ -154,7 +154,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/board/{{route.slug}}/post/{{item.id}}"
"path": "/admin/board/{{route.slug}}/post/{{item.id}}",
"mergeQuery": true,
"query": {}
}
}
],
@@ -766,7 +766,7 @@
"params": {
"actions": [
{ "handler": "setState", "params": { "target": "global", "activeBoardSettingsTab": "notification_definitions" } },
{ "handler": "replaceUrl", "params": { "query": { "tab": "notification_definitions" } } }
{ "comment": "audit:allow layout-list-context-navigate-merge-query 다른 탭으로 넘어가는 이동이다 — 알림 정의 탭은 자기 검색어와 페이지를 따로 갖는 별개의 목록이라 신고 정책 탭의 조건을 물려주지 않는다.", "handler": "replaceUrl", "params": { "query": { "tab": "notification_definitions" } } }
]
}
}
@@ -915,7 +915,7 @@
"params": {
"actions": [
{ "handler": "setState", "params": { "target": "global", "activeBoardSettingsTab": "notification_definitions" } },
{ "handler": "replaceUrl", "params": { "query": { "tab": "notification_definitions" } } }
{ "comment": "audit:allow layout-list-context-navigate-merge-query 다른 탭으로 넘어가는 이동이다 — 알림 정의 탭은 자기 검색어와 페이지를 따로 갖는 별개의 목록이라 신고 정책 탭의 조건을 물려주지 않는다.", "handler": "replaceUrl", "params": { "query": { "tab": "notification_definitions" } } }
]
}
}
@@ -0,0 +1,79 @@
/**
* 관리자 게시글 목록 컨텍스트 왕복 — 목록 상태(page/검색)가 상세·폼을 거쳐도 살아남는지 (공개 이슈 #75).
*
* 결함: 목록 클러스터(목록 ↔ 상세 ↔ 작성/수정 폼) 안의 이동에 `mergeQuery` 가 빠져 있어,
* 2페이지에서 글을 열었다가 목록으로 돌아오면 1페이지로 되돌아갔다. 특히 폼 '취소' 버튼은
* 목적지가 삼항 표현식이라 정적 검사에서도 오래 빠져 있었다 — 저장 leg 만 고쳐지고 취소 leg 는
* 남아 "저장하면 2페이지 유지 / 취소하면 1페이지 리셋" 비대칭이 생겼다.
*
* 단위/정적:
* - modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-list-context-round-trip.test.ts
* 가 실 JSON 의 액션 params 를 전수 고정
* - audit 룰 layout-list-context-navigate-merge-query 가 표현식 경로까지 판정해 재발 차단
* 이 spec 은 브라우저 수준(실제 주소창 URL)을 담당한다.
*
* 시드 의존: 샘플 시드의 자유게시판(`free`, 30건) — 관리자 목록 15건/페이지이므로 2페이지가 존재한다.
*
* @scenario board-list-context-round-trip
* @axes leg=enter leg=back
* @effects list_return_preserves_query
*/
import { test, expect, authenticatePage } from '../../fixtures/board-auth';
const SLUG = 'free';
const LIST_URL = `/admin/board/${SLUG}`;
/**
* 현재 URL 의 쿼리 파라미터를 돌려줍니다.
*
* @param url 페이지 URL
* @returns 쿼리 파라미터
*/
function queryOf(url: string): URLSearchParams {
return new URL(url).searchParams;
}
test.describe('관리자 게시글 목록 컨텍스트 왕복 (#75)', () => {
// @scenario leg=enter
// @effects list_return_preserves_query
test('2페이지에서 글 제목을 누르면 상세 URL 에 page 가 실린다', async ({ page, boardManageToken }) => {
await authenticatePage(page, boardManageToken);
await page.goto(`${LIST_URL}?page=2`);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
// 제목 Span 이 이동 액션을 갖는다 (행 전체가 아니라 제목만 클릭 대상).
await page.locator('tbody tr span.cursor-pointer').first().click();
await page.waitForURL(new RegExp(`/admin/board/${SLUG}/post/\\d+`), { timeout: 30_000 });
expect(queryOf(page.url()).get('page')).toBe('2');
});
// @scenario leg=back
// @effects list_return_preserves_query
test('상세에서 목록으로 돌아오면 2페이지가 복원된다', async ({ page, boardManageToken }) => {
await authenticatePage(page, boardManageToken);
await page.goto(`${LIST_URL}?page=2`);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
await page.locator('tbody tr span.cursor-pointer').first().click();
await page.waitForURL(new RegExp(`/admin/board/${SLUG}/post/\\d+`), { timeout: 30_000 });
await page.waitForLoadState('networkidle', { timeout: 30_000 });
await page.locator('#back_button').click();
await page.waitForURL(new RegExp(`/admin/board/${SLUG}\\?`), { timeout: 30_000 });
expect(queryOf(page.url()).get('page')).toBe('2');
});
// @scenario leg=back
// @effects list_return_preserves_query
test('작성 폼에서 취소하면 보고 있던 페이지로 돌아온다', async ({ page, boardManageToken }) => {
await authenticatePage(page, boardManageToken);
await page.goto(`${LIST_URL}/create?page=2`);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
await page.locator('#header_cancel_button').click();
await page.waitForURL(new RegExp(`/admin/board/${SLUG}\\?`), { timeout: 30_000 });
expect(queryOf(page.url()).get('page')).toBe('2');
});
});
@@ -0,0 +1,156 @@
/**
* 목록 컨텍스트 왕복 — 이전글/다음글 leg 의 URL 목록 상태 보존 (공개 이슈 #75).
*
* 결함: 목록에서 상세로 들어갈 때는 `mergeQuery: true` 로 page/search/category 가 상세 URL 에
* 실렸지만, 상세의 이전글/다음글 버튼은 규약에서 누락돼 `del` 하나만 남기고 나머지를 전부
* 떨궜다. 그 뒤 '목록' 버튼은 복원할 상태가 없어 1페이지로 되돌아갔다.
*
* 이 spec 은 시드 의존을 최소화한다 — 샘플 시드의 자유게시판(`free`, 28건/3페이지)만 있으면
* 돌아간다. 별도 전용 게시판(navtest) 시드를 요구하는 board-post-navigation.spec.ts 와 달리
* skip 없이 실행된다.
*
* 단위/렌더링:
* - templates/_bundled/sirsoft-basic/src/__tests__/layouts/board-post-navigation-list-return.test.tsx
* 가 실 `_navigation.json` 의 액션 params 로 mergeQuery 왕복을 고정
* - .claude/scripts/audit/rules/layout-list-context-navigate-merge-query.cjs 가 전 저장소 재발 차단
* 이 spec 은 브라우저 수준(실제 URL + 목록 API 요청 파라미터)을 담당한다.
*
* @scenario board-list-context-round-trip
* @axes leg=enter leg=back leg=prev leg=next
* @effects navigation_preserves_list_query,
* list_return_preserves_query
*/
import { test, expect } from '@playwright/test';
const BOARD_SLUG = 'free';
const LIST_URL = `/board/${BOARD_SLUG}`;
/**
* 현재 URL 의 쿼리 파라미터를 돌려줍니다.
*
* @param url 페이지 URL
* @returns 쿼리 파라미터
*/
function queryOf(url: string): URLSearchParams {
return new URL(url).searchParams;
}
/**
* 목록의 게시글 행 로케이터.
*
* basic 형 목록의 행은 `<a>` 가 아니라 navigate 액션이 달린 Button 이며, 접근성 이름이
* "번호 제목 … 작성일 조회수" 형태다. 페이지네이션 버튼("페이지 1")과 구분하기 위해
* 번호로 시작하는 이름만 고른다.
*
* @param page Playwright 페이지
* @returns 행 로케이터
*/
function postRows(page: import('@playwright/test').Page) {
return page.getByRole('button', { name: /^\d+\s+\S/ });
}
/**
* 목록 2페이지로 진입해 첫 글 상세까지 들어갑니다.
*
* @param page Playwright 페이지
* @returns 진입한 상세 URL
*/
async function enterDetailFromPage2(page: import('@playwright/test').Page): Promise<string> {
await page.goto(`${LIST_URL}?page=2`);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
await postRows(page).first().click();
await page.waitForURL(new RegExp(`/board/${BOARD_SLUG}/\\d+`), { timeout: 30_000 });
await page.waitForLoadState('networkidle', { timeout: 30_000 });
return page.url();
}
test.describe('목록 컨텍스트 왕복 — 이전글/다음글 (#75)', () => {
// @scenario leg=enter
// @effects list_return_preserves_query
test('목록 2페이지에서 상세로 들어가면 page 가 상세 URL 에 실린다', async ({ page }) => {
const detailUrl = await enterDetailFromPage2(page);
expect(detailUrl).toContain(`/board/${BOARD_SLUG}/`);
expect(queryOf(detailUrl).get('page')).toBe('2');
});
// @scenario leg=prev
// @effects navigation_preserves_list_query
test('이전글로 이동해도 page 가 유지된다', async ({ page }) => {
await enterDetailFromPage2(page);
const prev = page.getByRole('button', { name: /(이전글|Previous)(?!\s*page)/i }).first();
await prev.click();
await page.waitForLoadState('networkidle', { timeout: 30_000 });
expect(queryOf(page.url()).get('page')).toBe('2');
});
// @scenario leg=next
// @effects navigation_preserves_list_query
test('다음글로 이동해도 page 가 유지된다', async ({ page }) => {
await enterDetailFromPage2(page);
const next = page.getByRole('button', { name: /(다음글|Next)(?!\s*page)/i }).first();
await next.click();
await page.waitForLoadState('networkidle', { timeout: 30_000 });
expect(queryOf(page.url()).get('page')).toBe('2');
});
// @scenario leg=prev, leg=back
// @effects navigation_preserves_list_query, list_return_preserves_query
test('이전글 3회 연속 후 목록으로 돌아가도 2페이지가 복원된다', async ({ page }) => {
await enterDetailFromPage2(page);
for (let i = 0; i < 3; i++) {
const prev = page.getByRole('button', { name: /(이전글|Previous)(?!\s*page)/i }).first();
if (!(await prev.isVisible().catch(() => false))) break;
await prev.click();
await page.waitForLoadState('networkidle', { timeout: 30_000 });
expect(queryOf(page.url()).get('page'), `${i + 1}회차 이전글 이동에서 page 소실`).toBe('2');
}
// 목록 복귀 — 목록 API 가 실제로 2페이지를 요청해야 한다.
// 직전 이동 직후에는 상세가 다시 렌더되며 버튼이 교체되므로, 버튼이 안정된 뒤 클릭한다.
const listButton = page.getByRole('button', { name: /(목록|Back to List)$/i }).first();
await expect(listButton).toBeVisible({ timeout: 30_000 });
const [listRequest] = await Promise.all([
page.waitForRequest(
(req) => req.url().includes('/posts') && req.url().includes('page=2'),
{ timeout: 30_000 }
),
listButton.click(),
]);
expect(listRequest.url()).toContain('page=2');
await page.waitForURL(new RegExp(`/board/${BOARD_SLUG}\\?`), { timeout: 30_000 });
expect(queryOf(page.url()).get('page')).toBe('2');
});
// @scenario leg=prev
// @effects navigation_preserves_list_query
test('검색어가 걸린 상태에서도 이전글 이동이 검색어를 유지한다', async ({ page }) => {
await page.goto(`${LIST_URL}?page=1&search=맛집`);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
const row = postRows(page).first();
if (!(await row.isVisible().catch(() => false))) {
test.skip(true, '검색 결과가 없어 이 축을 실행할 수 없습니다.');
return;
}
await row.click();
await page.waitForURL(new RegExp(`/board/${BOARD_SLUG}/\\d+`), { timeout: 30_000 });
await page.waitForLoadState('networkidle', { timeout: 30_000 });
expect(queryOf(page.url()).get('search')).toBe('맛집');
const prev = page.getByRole('button', { name: /(이전글|Previous)(?!\s*page)/i }).first();
if (await prev.isVisible().catch(() => false)) {
await prev.click();
await page.waitForLoadState('networkidle', { timeout: 30_000 });
expect(queryOf(page.url()).get('search')).toBe('맛집');
}
});
});
@@ -0,0 +1,46 @@
feature: 목록 컨텍스트 왕복 시 URL 목록 상태 보존
description: |
페이지네이션 목록 화면과 그에 딸린 상세·형제 상세(이전글/다음글)·작성/수정 폼·확인 모달은
하나의 "목록 클러스터" 다. 이 클러스터 안에서의 모든 이동은 URL 에 실린 목록 상태
(page / search / category / filters[*] / 정렬 / per_page)를 손실 없이 이어받아야 한다.
공개 이슈 #75 (@jiwonpapa) 는 이 규약의 **부분 적용**이 원인이었다.
- 45-1 에서 목록 진입(enter)·목록 복귀(back) leg 에는 mergeQuery:true 가 적용됐다.
- 그러나 같은 화면의 이전글/다음글(prev/next) leg 은 누락돼 `del` 하나만 남기고
page/search/category 를 전부 떨궜다.
- 그 뒤 '목록' 버튼은 복원할 상태가 없어 1페이지로 되돌아갔다.
따라서 변별 축은 글 유형이 아니라 **이동 leg** 이다. post_kind 와는 직교하지 않는다 —
답글·공지 상세에는 prev/next leg 자체가 존재하지 않으며, 그 부재는
board-post-navigation.yaml 의 navigation_null_for_reply / navigation_null_for_notice 가 담당한다.
수정 범위는 게시판에 한정되지 않는다. 동일 결함이 관리자 게시판·이커머스·페이지·회원·
마이페이지 등 9개 확장에 걸쳐 있었고, 전 저장소를 한 번에 교정했다. 재발은 audit 룰
`layout-list-context-navigate-merge-query` (error) 가 차단한다.
axes:
# 목록 클러스터 안에서 발생하는 이동 leg. 하나라도 빠지면 그 지점에서 상태가 끊긴다.
leg: [enter, back, prev, next] # 목록→상세 / 상세→목록 / 이전글 / 다음글
effects:
- navigation_preserves_list_query # 형제 상세(prev/next) 이동이 목록 상태를 승계
- list_return_preserves_query # 목록 복귀가 상세 URL 의 목록 상태를 그대로 복원
test_files:
- modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-list-context-round-trip.spec.ts
- modules/_bundled/sirsoft-board/tests/Playwright/specs/admin/board-admin-list-context-round-trip.spec.ts
- templates/_bundled/sirsoft-basic/src/__tests__/layouts/board-post-navigation-list-return.test.tsx
- modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-list-context-round-trip.test.ts
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-list-context-round-trip.test.ts
- modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-list-context-round-trip.test.ts
- templates/_bundled/sirsoft-admin_basic/src/__tests__/layouts/admin-list-context-round-trip.test.ts
- .claude/scripts/audit/__tests__/layout-list-context-navigate-merge-query.test.cjs
validation:
note: |
leg 4종은 E2E(board-list-context-round-trip.spec.ts)가 실제 브라우저 URL 과 목록 API 요청
파라미터로 실측한다. 확장별 레이아웃 테스트는 실 JSON 의 액션 params 를 읽어 클러스터 내
navigate 가 전부 mergeQuery:true 인지 전수 고정하고, audit 룰 테스트가 저장소 전체 잔여
위반 0건을 회귀로 못박는다. 엔진 게이트(mergeQuery:true + query 생략) 는
ActionDispatcher.test.ts 가 navigate/replaceUrl 양쪽에서 검증한다.
@@ -28,6 +28,9 @@ axes:
# navigation 결과를 가르는 핵심 변별 축은 글 유형(post_kind).
# 카테고리 유무·목록 위치·비밀/블라인드 변종은 effects 와 개별 테스트 메서드로 커버한다.
post_kind: [original, reply, notice] # 원글(정상 순회) / 답글(미노출) / 공지(미노출)
# 목록 상태 보존(#75)의 변별 축은 "어느 leg 으로 이동했는가" 이며 post_kind 와 직교하지 않는다
# (답글·공지에는 이전/다음 leg 자체가 없다). 별도 기능으로 분리한다 →
# tests/scenarios/board-list-context-round-trip.yaml
effects:
- navigation_limited_to_same_category # 47-1: 동일 카테고리 글만 prev/next
@@ -38,12 +41,14 @@ effects:
- navigation_blinded_excluded # 47-2: 블라인드 제외 (기존 유지)
- navigation_boundary_null # 47-3: 첫/마지막 글 prev/next null
- list_return_preserves_query # 45-1: 행 클릭→상세→목록 복귀 시 query 보존
- navigation_preserves_list_query # 75: 이전글/다음글 leg 도 목록 상태(page/search/category)를 승계
test_files:
- modules/_bundled/sirsoft-board/tests/Feature/User/PostNavigationTest.php
- modules/_bundled/sirsoft-board/tests/Feature/User/PostNavigationApiTest.php
- templates/_bundled/sirsoft-basic/src/__tests__/layouts/board-post-navigation-list-return.test.tsx
- modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-post-navigation.spec.ts
- modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-list-context-round-trip.spec.ts
validation:
note: |
@@ -18,6 +18,14 @@
### Fixed
- 휴대폰으로 관리자 주문 목록을 볼 때 왼쪽 위 '뒤로' 버튼을 누르면 이전 화면이 아니라 빈 페이지로 넘어가던 문제를 수정했습니다.
- 관리자 주문·상품·쿠폰·배송정책·리뷰·마일리지 목록에서 상세나 등록/수정 화면에 다녀오면 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. 필터를 여러 개 건 상태에서 주문 하나를 확인하고 돌아와도 조건이 그대로 유지됩니다. 정렬·표시 개수를 바꾸거나 새로고침해도 걸어 둔 조건은 그대로입니다. 반대로 초기화 버튼과 프리셋 적용은 종전처럼 조건을 새로 세팅합니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 브랜드·상품 공통정보·상품 고시정보 목록에서 검색어(브랜드는 정렬 포함)를 걸어 둔 채 새로고침 버튼을 누르면 걸어 둔 검색이 풀려 전체 목록으로 돌아가던 문제를 수정했습니다. 이제 새로고침은 보던 조건 그대로 다시 불러옵니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 주문 목록에서 '이 주문자의 주문 검색'을 실행하면 검색창은 비어 보이는데 직전 검색어가 계속 적용돼 결과가 어긋나던 문제를 수정했습니다. 비회원 주문자명으로 검색할 때 회원 지정 조건이 함께 걸려 결과가 비어 보이던 문제도 함께 해소되며, 주문 상세의 주문자 카드에서 같은 검색을 실행할 때도 동일하게 적용됩니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 브랜드 관리에서 브랜드명으로 검색하면 결과가 항상 비어 있던 문제를 수정했습니다. 이제 브랜드명·slug·웹사이트 주소 중 하나만 일치해도 검색됩니다.
- 관리자 상품 등록/수정 화면의 배송 정보에서 '배송정책 등록' 버튼을 눌러도 아무 반응이 없던 문제를 수정했습니다. 이제 배송정책 등록 화면으로 이동합니다.
- 관리자 브랜드·상품 카테고리·상품 공통정보·상품 고시정보 목록에서 왼쪽 목록의 항목을 고르면 걸어 둔 검색어(브랜드는 정렬 포함)가 사라지던 문제를 수정했습니다. 항목 선택은 선택 상태만 주소에 기록하고 목록 조건은 그대로 둡니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 쿠폰 목록에서 '등록' 으로 들어가거나 상품을 새로 저장한 직후, 보고 있던 목록의 조건이 사라져 취소·복귀 시 1페이지로 돌아가던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 상품을 장바구니에 담거나 바로 구매할 때, 표시 언어(로케일)나 관리자의 옵션 이름 수정과 무관하게 선택한 옵션이 정확히 담기도록 개선했습니다. 특정 상황에서 "해당 옵션 조합을 찾을 수 없습니다" 오류로 담기가 실패하던 문제가 해소됩니다.
- 오류 안내가 뜨기는 하지만 내용이 비어 있던 문제를 수정했습니다. 서버가 알려 준 실패 사유가 그대로 표시됩니다.
- 헤더에서 통화나 배송 국가를 바꿀 때 저장에 실패해도 아무 안내 없이 넘어가던 문제를 수정했습니다. 실패하면 그 이유가 화면에 표시됩니다.
@@ -0,0 +1,344 @@
/**
* @file admin-list-context-round-trip.test.ts
* @description 관리자 이커머스 목록 컨텍스트 왕복 시 URL 목록 상태 보존 회귀 (#75)
*
* 결함: 주문/상품/쿠폰/배송정책/리뷰/마일리지 목록에서 상세·폼으로 들어갔다 돌아오면
* navigate 가 `mergeQuery` 없이 실행되어 page 와 필터 20여 종이 URL 에서 사라졌다.
* 필터를 여러 개 건 상태일수록 손실이 크다.
*
* 검증 방식 2단:
* 1. 실 레이아웃 JSON 전수 분석 — 클러스터 내 navigate 가 모두 `mergeQuery: true` 인지
* 2. 실 액션을 엔진에 태워 실행(`triggerAction`) — 선언이 실제 목적지 URL 로 이어지는지
* 손으로 쓴 조각이 아니라 실파일을 읽는다. 선언만 검사하면 "JSON 에 리터럴이 적혀 있다"
* 까지만 고정되고 병합 결과는 미검증으로 남는다.
*
* @scenario leg=row_click|detail_return|form_cancel|filter_apply
* @effects navigation_preserves_list_query
*
* @vitest-environment jsdom
*/
import { describe, it, expect, afterEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import {
createLayoutTest,
createMockComponentRegistryWithBasics,
} from '@core/template-engine/__tests__/utils/layoutTestUtils';
const LAYOUTS_ROOT = path.resolve(__dirname, '../../../layouts/admin');
/** 이 확장이 소유한 페이지네이션 목록 라우트 */
const LIST_ROUTES = [
'/admin/ecommerce/orders',
'/admin/ecommerce/products',
'/admin/ecommerce/reviews',
'/admin/ecommerce/promotion-coupons',
'/admin/ecommerce/shipping-policies',
'/admin/ecommerce/mileage-transactions',
];
/** 목록 클러스터에 속하는 레이아웃 파일 */
const CLUSTER_FILES = [
'admin_ecommerce_order_detail.json',
'admin_ecommerce_order_index.json',
'admin_ecommerce_order_list.json',
'admin_ecommerce_product_form.json',
'admin_ecommerce_product_list.json',
'admin_ecommerce_product_review_index.json',
'admin_ecommerce_promotion_coupon_form.json',
'admin_ecommerce_promotion_coupon_list.json',
'admin_ecommerce_shipping_policy_form.json',
'admin_ecommerce_shipping_policy_list.json',
'partials/admin_ecommerce_mileage_transaction_index/_filters.json',
'partials/admin_ecommerce_mileage_transaction_index/_transactions_table.json',
'partials/admin_ecommerce_order_detail/_partial_order_info.json',
'partials/admin_ecommerce_order_list/_partial_filter_section.json',
'partials/admin_ecommerce_order_list/_partial_order_datagrid.json',
'partials/admin_ecommerce_order_list/_partial_preset_section.json',
'partials/admin_ecommerce_product_form/_modal_delete_confirm.json',
'partials/admin_ecommerce_product_list/_partial_filter_section.json',
'partials/admin_ecommerce_product_list/_partial_product_datagrid.json',
'partials/admin_ecommerce_promotion_coupon_list/_partial_coupon_datagrid.json',
'partials/admin_ecommerce_promotion_coupon_list/_partial_filter_section.json',
'partials/admin_ecommerce_shipping_policy_list/_modal_copy.json',
'partials/admin_ecommerce_shipping_policy_list/_partial_datagrid.json',
'partials/admin_ecommerce_shipping_policy_list/_partial_filter.json',
];
/**
* 목적지 경로(`path`)가 없는 선택형 navigate/replaceUrl 을 가진 목록 화면.
*
* `path` 를 생략한 액션은 "현재 URL 을 유지한 채 쿼리만 바꾼다" 는 뜻이라 목록 화면
* 자신에게 작용한다. 그런데 `mergeQuery` 가 없으면 엔진이 대체 분기로 가 쿼리를
* **통째로** 새 값으로 바꿔치므로, 방금 건 검색어·정렬이 항목 선택 한 번에 사라진다.
* 목적지가 문자열로 안 적혀 있어 경로 기반 검사(`normalizePath`)의 사각지대다.
*/
const SELF_QUERY_FILES = [
'partials/admin_ecommerce_brand_index/_panel_brand_list.json',
'partials/admin_ecommerce_category_index/_panel_category_list.json',
'partials/admin_ecommerce_product_common_info_index/_panel_list.json',
'partials/admin_ecommerce_product_notice_index/_panel_list.json',
];
type NavAction = {
handler: string;
comment?: string;
params: { path?: string; mergeQuery?: unknown; query?: unknown };
};
/**
* JSON 트리에서 navigate/replaceUrl 액션을 모두 수집합니다.
*
* @param node 순회 시작 노드
* @returns 액션 목록
*/
function collectNavActions(node: unknown): NavAction[] {
const out: NavAction[] = [];
const walk = (n: unknown): void => {
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
if (!n || typeof n !== 'object') return;
const obj = n as Record<string, unknown>;
if (
(obj.handler === 'navigate' || obj.handler === 'replaceUrl') &&
obj.params &&
typeof obj.params === 'object'
) {
out.push(obj as unknown as NavAction);
}
Object.values(obj).forEach(walk);
};
walk(node);
return out;
}
/**
* 목적지 경로를 비교용 표준형(`{{expr}}` → `*`)으로 바꿉니다.
*
* @param raw 원본 경로
* @returns 표준형 경로 (판정 불가 시 null)
*/
function normalizePath(raw: unknown): string | null {
if (typeof raw !== 'string' || raw === '') return null;
const p = raw.replace(/\{\{[^}]*\}\}/g, '*').split('?')[0].replace(/\/+$/, '');
return p.startsWith('/') ? p : null;
}
/**
* 경로가 목록 라우트와 같은 클러스터인지 판정합니다.
*
* @param dest 표준형 목적지
* @param list 표준형 목록 라우트
* @returns 같은 클러스터 여부
*/
function inFamily(dest: string, list: string): boolean {
return dest === list || dest.startsWith(`${list}/`);
}
/**
* 레이아웃 파일을 읽어 파싱합니다.
*
* @param rel 레이아웃 루트 기준 상대 경로
* @returns 파싱된 JSON
*/
function readLayout(rel: string): unknown {
return JSON.parse(fs.readFileSync(path.join(LAYOUTS_ROOT, rel), 'utf8'));
}
describe('#75 관리자 이커머스 목록 컨텍스트 왕복 — mergeQuery 전수', () => {
it.each(CLUSTER_FILES)('%s 의 클러스터 내 navigate 는 모두 mergeQuery: true', (rel) => {
const offenders = collectNavActions(readLayout(rel))
.filter((a) => {
if (typeof a.comment === 'string' && a.comment.includes('audit:allow')) return false;
const dest = normalizePath(a.params.path);
if (!dest) return false;
if (!LIST_ROUTES.some((l) => inFamily(dest, l))) return false;
return a.params.mergeQuery !== true;
})
.map((a) => `${a.params.path} (mergeQuery=${JSON.stringify(a.params.mergeQuery)})`);
expect(offenders, `목록 상태를 떨구는 navigate: \n ${offenders.join('\n ')}`).toEqual([]);
});
});
describe('#75 목적지가 자기 자신인 액션도 목록 상태를 유지한다', () => {
it.each(SELF_QUERY_FILES)('%s 의 path 없는 navigate/replaceUrl 은 mergeQuery: true', (rel) => {
const offenders = collectNavActions(readLayout(rel))
.filter((a) => {
if (typeof a.comment === 'string' && a.comment.includes('audit:allow')) return false;
if (normalizePath(a.params.path) !== null) return false;
return a.params.mergeQuery !== true;
})
.map((a) => `${a.handler} query=${JSON.stringify(a.params.query)}`);
expect(
offenders,
`현재 URL 의 검색어·정렬을 통째로 덮어쓰는 액션: \n ${offenders.join('\n ')}`
).toEqual([]);
});
it('브랜드를 선택해도 걸어둔 검색어와 정렬이 URL 에 남는다', async () => {
window.history.replaceState({}, '', '/admin/ecommerce/brands?search=나이키&sort=name_desc');
const action = collectNavActions(
readLayout('partials/admin_ecommerce_brand_index/_panel_brand_list.json')
).find((a) => a.handler === 'replaceUrl' && normalizePath(a.params.path) === null);
expect(action, '브랜드 선택 replaceUrl 을 찾지 못했습니다.').toBeTruthy();
await runAction(action as NavAction);
const q = new URLSearchParams(window.location.search);
expect(q.get('search')).toBe('나이키');
expect(q.get('sort')).toBe('name_desc');
expect(q.get('mode')).toBe('view');
});
});
describe('#75 주문자명 검색은 회원 지정 필터를 함께 비운다', () => {
/**
* `orderer_uuid`(회원 지정)와 `search_field=orderer_name`(이름 문자열)은 서로 배타적이다.
* `mergeQuery: true` 가 붙은 뒤로는 이전 화면의 `orderer_uuid` 가 살아남으므로,
* 이름 검색을 걸면 두 조건이 AND 로 겹쳐 결과가 비어 보인다. 이름 검색을 실행하는
* 모든 leg 이 `orderer_uuid` 를 명시적으로 비워야 한다.
*/
const ORDERER_SEARCH_LEGS: Array<[string, string]> = [
['목록 데이터그리드', 'partials/admin_ecommerce_order_list/_partial_order_datagrid.json'],
['주문 상세 주문자 카드', 'partials/admin_ecommerce_order_detail/_partial_order_info.json'],
];
it.each(ORDERER_SEARCH_LEGS)('%s 의 주문자명 검색이 orderer_uuid 를 비운다', (_label, rel) => {
const actions = collectNavActions(readLayout(rel)).filter(
(a) =>
a.params.query !== null &&
typeof a.params.query === 'object' &&
(a.params.query as Record<string, unknown>).search_field === 'orderer_name'
);
expect(actions.length, '주문자명 검색 navigate 를 찾지 못했습니다.').toBeGreaterThan(0);
actions.forEach((a) => {
const query = a.params.query as Record<string, unknown>;
expect(Object.keys(query)).toContain('orderer_uuid');
expect(query.orderer_uuid).toBe('');
});
});
});
describe('#75 주문 목록 ↔ 상세 왕복 leg 고정', () => {
it('주문 행 클릭이 필터/페이지를 상세로 나른다', () => {
const actions = collectNavActions(
readLayout('partials/admin_ecommerce_order_list/_partial_order_datagrid.json')
).filter((a) => String(a.params.path ?? '').startsWith('/admin/ecommerce/orders/{{'));
expect(actions.length).toBeGreaterThan(0);
actions.forEach((a) => expect(a.params.mergeQuery).toBe(true));
});
it("주문 상세 '목록' 버튼이 필터/페이지를 복원한다", () => {
const a = collectNavActions(readLayout('admin_ecommerce_order_detail.json')).find(
(x) => x.params.path === '/admin/ecommerce/orders'
);
expect(a, "주문 상세의 '목록' navigate 를 찾지 못했습니다.").toBeTruthy();
expect(a?.params.mergeQuery).toBe(true);
});
});
// ---------------------------------------------------------------------------
// 실행 검증 — 실 액션을 엔진에 태워 목적지 URL 을 확인한다
// ---------------------------------------------------------------------------
/**
* 실 JSON 액션을 현재 URL 상태에서 실행하고 목적지를 돌려줍니다.
*
* @param action 실 레이아웃 JSON 에서 뽑은 navigate 액션
* @returns navigate 목적지 경로
*/
async function runNav(action: NavAction): Promise<string> {
return (await runAction(action)) as string;
}
/**
* 실 JSON 액션을 현재 URL 상태에서 실행합니다.
*
* `navigate` 는 목적지가 navigation history 에 쌓이지만 `replaceUrl` 은 주소만 바꾸므로
* 히스토리가 비어 있다. 결과 확인은 호출 측이 목적으로 나눠서 한다 —
* navigate 는 반환값, replaceUrl 은 `window.location`.
*
* @param action 실 레이아웃 JSON 에서 뽑은 navigate/replaceUrl 액션
* @returns navigate 목적지 경로 (replaceUrl 이면 undefined)
*/
async function runAction(action: NavAction): Promise<string | undefined> {
const utils = createLayoutTest(
{
version: '1.0.0',
layout_name: 'nav_action_probe',
data_sources: [],
components: [{ type: 'basic', name: 'Div', props: { 'data-testid': 'probe' } }],
} as never,
{ componentRegistry: createMockComponentRegistryWithBasics() as never, locale: 'ko' }
);
await utils.render();
await utils.triggerAction({ type: 'click', ...action } as never);
const dest = utils.getNavigationHistory()[0];
utils.cleanup();
return dest;
}
/**
* 목적지 URL 의 쿼리스트링을 파싱합니다.
*
* @param dest navigate 목적지
* @returns URLSearchParams
*/
function queryOf(dest: string): URLSearchParams {
return new URLSearchParams(dest.includes('?') ? dest.slice(dest.indexOf('?')) : '');
}
describe('#75 주문 목록 ↔ 상세 왕복 — 실행 결과 URL', () => {
afterEach(() => {
window.history.replaceState({}, '', '/');
});
/** 필터를 여러 개 건 목록 URL — 이 화면이 손실 폭이 가장 크다 */
const LIST_URL =
'/admin/ecommerce/orders?page=2&per_page=20&search_field=orderer_name&search=홍길동&order_status=confirmed';
it('주문 행 클릭이 필터 전부를 상세 URL 로 나른다', async () => {
window.history.replaceState({}, '', LIST_URL);
const action = collectNavActions(
readLayout('partials/admin_ecommerce_order_list/_partial_order_datagrid.json')
).find((a) => String(a.params.path ?? '').startsWith('/admin/ecommerce/orders/{{'));
expect(action, '주문 행 클릭 navigate 를 찾지 못했습니다.').toBeTruthy();
const q = queryOf(await runNav(action as NavAction));
expect(q.get('page')).toBe('2');
expect(q.get('per_page')).toBe('20');
expect(q.get('search_field')).toBe('orderer_name');
expect(q.get('search')).toBe('홍길동');
expect(q.get('order_status')).toBe('confirmed');
});
it("주문 상세 '목록' 버튼이 필터 전부를 목록으로 복원한다", async () => {
window.history.replaceState(
{},
'',
'/admin/ecommerce/orders/1024?page=2&per_page=20&search=홍길동&order_status=confirmed'
);
const action = collectNavActions(readLayout('admin_ecommerce_order_detail.json')).find(
(x) => x.params.path === '/admin/ecommerce/orders'
);
const q = queryOf(await runNav(action as NavAction));
expect(q.get('page')).toBe('2');
expect(q.get('per_page')).toBe('20');
expect(q.get('search')).toBe('홍길동');
expect(q.get('order_status')).toBe('confirmed');
});
});
@@ -144,7 +144,8 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/brands",
"mergeQuery": false
"mergeQuery": true,
"query": {}
}
}
],
@@ -318,7 +318,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders"
"path": "/admin/ecommerce/orders",
"mergeQuery": true,
"query": {}
}
}
]
@@ -185,7 +185,8 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders",
"mergeQuery": false
"mergeQuery": true,
"query": {}
}
}
]
@@ -380,11 +380,9 @@
],
"actions": [
{
"comment": "브라우저 히스토리 뒤로가기 — navigate 는 path 를 주소로 해석하므로 \"back\" 을 주면 /back 으로 이동한다",
"type": "click",
"handler": "navigate",
"params": {
"path": "back"
}
"handler": "navigateBack"
}
]
},
@@ -444,11 +442,13 @@
],
"actions": [
{
"comment": "새로고침은 보고 있던 목록을 그대로 다시 부르는 것이다 — 페이지와 걸어둔 필터 20여 종을 유지한다 (#75).",
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders",
"mergeQuery": false
"mergeQuery": true,
"query": {}
}
}
]
@@ -122,7 +122,8 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/common-info",
"mergeQuery": false
"mergeQuery": true,
"query": {}
}
}
],
@@ -882,7 +882,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/products"
"path": "/admin/ecommerce/products",
"mergeQuery": true,
"query": {}
}
}
]
@@ -1053,10 +1055,13 @@
}
},
{
"comment": "신규 저장 후 편집 화면으로 전환 — 같은 목록 클러스터 안의 이동이므로 목록 상태를 유지한다 (#75).",
"handler": "navigate",
"if": "{{!route.itemCode}}",
"params": {
"path": "{{'/admin/ecommerce/products/' + result.data.id + '/edit'}}"
"path": "{{'/admin/ecommerce/products/' + result.data.id + '/edit'}}",
"mergeQuery": true,
"query": {}
}
}
],
@@ -424,7 +424,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/products/create"
"path": "/admin/ecommerce/products/create",
"mergeQuery": true,
"query": {}
}
}
]
@@ -134,7 +134,8 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/product-notices",
"mergeQuery": false
"mergeQuery": true,
"query": {}
}
}
],
@@ -177,10 +177,13 @@
],
"actions": [
{
"comment": "새로고침은 보고 있던 목록을 그대로 다시 부르는 것이다 — 페이지/검색/필터를 유지한다 (#75).",
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"mergeQuery": true,
"query": {},
"replace": true
}
}
@@ -406,12 +409,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"rating": "",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -438,12 +441,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"rating": "5",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -470,12 +473,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"rating": "4",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -502,12 +505,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"rating": "3",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -534,12 +537,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"rating": "2",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -566,12 +569,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"rating": "1",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -625,12 +628,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"reply_status": "",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -657,12 +660,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"reply_status": "replied",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -689,12 +692,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"reply_status": "not_replied",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -748,12 +751,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"photo": "",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -780,12 +783,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"photo": "photo",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -812,12 +815,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"photo": "normal",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -871,12 +874,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"status": "",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -903,12 +906,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"status": "visible",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -935,12 +938,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"status": "hidden",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -1282,7 +1285,7 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"search_field": "{{_local.filter.searchField !== 'product_name' ? _local.filter.searchField : ''}}",
"search_keyword": "{{_local.filter.searchKeyword ?? ''}}",
@@ -1296,6 +1299,7 @@
"per_page": "{{query.per_page || 20}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -1335,6 +1339,7 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 필터 초기화 — 걸려 있던 조건을 전부 비우는 것이 이 버튼의 목적이므로 병합하지 않는다.",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
@@ -1511,12 +1516,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"sort": "{{$event.target.value}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -1554,12 +1559,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"per_page": "{{$event.target.value}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -2908,11 +2913,11 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/reviews",
"replace": true,
"mergeQuery": true,
"query": {
"page": "{{$args[0]}}"
},
"replace": true,
"transition_overlay_target": "review_datagrid__body"
}
}
@@ -170,7 +170,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/promotion-coupons"
"path": "/admin/ecommerce/promotion-coupons",
"mergeQuery": true,
"query": {}
}
}
]
@@ -230,7 +232,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/promotion-coupons"
"path": "/admin/ecommerce/promotion-coupons",
"mergeQuery": true,
"query": {}
}
}
]
@@ -300,7 +304,9 @@
{
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/promotion-coupons"
"path": "/admin/ecommerce/promotion-coupons",
"mergeQuery": true,
"query": {}
}
}
],
@@ -223,7 +223,7 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/promotion-coupons",
"replace": true,
"mergeQuery": true,
"query": {
"search_field": "{{_local.filter.searchField !== 'all' ? _local.filter.searchField : ''}}",
"search_keyword": "{{_local.filter.searchKeyword}}",
@@ -244,6 +244,7 @@
"created_by": "{{_local.filter.createdBy}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "pc_data_table__body"
}
}
@@ -340,10 +341,13 @@
],
"actions": [
{
"comment": "쿠폰 등록 폼 진입 — 폼에서 취소하고 돌아왔을 때 보던 목록으로 복귀하려면 진입 시점에 목록 상태를 넘겨야 한다 (#75).",
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/promotion-coupon-create"
"path": "/admin/ecommerce/promotion-coupon-create",
"mergeQuery": true,
"query": {}
}
}
]
@@ -348,6 +348,7 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 탭 전환 — 탭마다 다루는 대상이 달라 이전 탭의 목록 상태를 물려받으면 오히려 잘못된 화면이 된다.",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/settings",
@@ -329,7 +329,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/shipping-policies"
"path": "/admin/ecommerce/shipping-policies",
"mergeQuery": true,
"query": {}
}
}
]
@@ -406,7 +408,9 @@
{
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/shipping-policies"
"path": "/admin/ecommerce/shipping-policies",
"mergeQuery": true,
"query": {}
}
}
],
@@ -107,7 +107,7 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/shipping-policies",
"replace": true,
"mergeQuery": true,
"query": {
"page": 1,
"search": "{{_local.filter.search || undefined}}",
@@ -116,6 +116,7 @@
"countries[]": "{{_local.filter.countries?.length > 0 ? _local.filter.countries : undefined}}",
"is_active": "{{_local.filter.is_active || undefined}}"
},
"replace": true,
"transition_overlay_target": "shipping_policy_datagrid__body"
}
}
@@ -188,7 +189,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/shipping-policies/create"
"path": "/admin/ecommerce/shipping-policies/create",
"mergeQuery": true,
"query": {}
}
}
]
@@ -268,13 +271,13 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/shipping-policies",
"replace": true,
"mergeQuery": true,
"query": {
"sort_by": "{{$event.target.value.split('_').slice(0, -1).join('_')}}",
"sort_order": "{{$event.target.value.split('_').pop()}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "shipping_policy_datagrid__body"
}
}
@@ -312,12 +315,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/shipping-policies",
"replace": true,
"mergeQuery": true,
"query": {
"per_page": "{{$event.target.value}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "shipping_policy_datagrid__body"
}
}
@@ -139,6 +139,7 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/brands",
"mergeQuery": true,
"query": {
"search": "{{query.search}}",
"sort": "{{$event.target.value}}"
@@ -196,8 +197,10 @@
}
},
{
"comment": "선택 상태만 주소에 기록한다 — 왼쪽 목록에 걸어둔 검색어·정렬은 그대로 둔다. 병합하지 않으면 항목을 고르는 순간 목록이 초기화된다 (#75).",
"handler": "replaceUrl",
"params": {
"mergeQuery": true,
"query": {
"id": "{{$args[0].id}}",
"mode": "view"
@@ -89,8 +89,10 @@
}
},
{
"comment": "선택 상태만 주소에 기록한다 — 왼쪽 목록에 걸어둔 조건은 그대로 둔다 (#75).",
"handler": "replaceUrl",
"params": {
"mergeQuery": true,
"query": {
"id": "{{$args[0]?.id}}",
"mode": "view"
@@ -456,11 +456,13 @@
"text": "$t:sirsoft-ecommerce.admin.mileage_transactions.search.submit",
"actions": [
{
"comment": "검색 실행 — 열거한 검색 조건만 덮어쓰고 나머지 목록 상태는 유지한다 (#75).",
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/mileage-transactions",
"replace": true,
"mergeQuery": true,
"query": {
"search_field": "{{_local.filter.searchField ?? 'member'}}",
"search_keyword": "{{_local.filter.searchKeyword ?? ''}}",
@@ -514,10 +516,12 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 필터 초기화 — 걸려 있던 조건을 전부 비우는 것이 이 버튼의 목적이므로 병합하지 않는다.",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/mileage-transactions",
"replace": true,
"mergeQuery": false,
"transition_overlay_target": "mileage_transactions_datagrid__body"
}
}
@@ -135,12 +135,13 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/mileage-transactions",
"replace": true,
"mergeQuery": true,
"query": {
"search_field": "member_id",
"search_keyword": "{{row.user_id}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "mileage_transactions_datagrid__body"
}
}
@@ -581,11 +582,11 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/mileage-transactions",
"replace": true,
"mergeQuery": true,
"query": {
"page": "{{$args[0]}}"
},
"replace": true,
"transition_overlay_target": "mileage_transactions_datagrid__body"
}
},
@@ -595,12 +596,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/mileage-transactions",
"replace": true,
"mergeQuery": true,
"query": {
"sort": "{{$args[0]}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "mileage_transactions_datagrid__body"
}
},
@@ -1296,12 +1296,15 @@
}
},
"search_by_orderer": {
"comment": "주문자명으로 검색 — 회원 지정 필터(orderer_uuid)를 함께 비우지 않으면 이름 검색과 회원 필터가 겹쳐 결과가 비어 보인다 (#75).",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders",
"mergeQuery": true,
"query": {
"search_field": "orderer_name",
"search_keyword": "{{order.data?.orderer_name}}",
"orderer_uuid": "",
"page": 1
},
"transition_overlay_target": "order_product_datagrid__body"
@@ -2093,6 +2093,7 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 프리셋 적용 — 프리셋은 조건 세트를 통째로 갈아끼우는 것이므로 병합하면 직전 조건(배송정책·회원구분 등 여기 열거되지 않은 필터)이 화면에 보이지 않는 채로 남는다. 같은 화면의 _partial_preset_section.json 과 동일 규약.",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders",
@@ -2640,6 +2641,7 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 필터 초기화 — 걸려 있던 조건을 전부 비우는 것이 이 버튼의 목적이므로 병합하지 않는다.",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders",
@@ -100,12 +100,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders",
"replace": true,
"mergeQuery": true,
"query": {
"sort_by": "{{$event.target.value.split('_').slice(0,-1).join('_')}}",
"sort_order": "{{$event.target.value.split('_').pop()}}"
}
},
"replace": true
}
}
]
@@ -155,12 +155,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders",
"replace": true,
"mergeQuery": true,
"query": {
"per_page": "{{$event.target.value}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "order_datagrid__body"
}
}
@@ -266,7 +266,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders/{{row.order_number}}"
"path": "/admin/ecommerce/orders/{{row.order_number}}",
"mergeQuery": true,
"query": {}
}
}
]
@@ -597,11 +599,15 @@
}
},
{
"comment": "이 주문자의 주문만 보기 — 앞선 setState 가 검색어를 비우므로 URL 의 검색 조건도 함께 비운다. 비우지 않으면 화면 입력창은 빈 채로 이전 검색어가 계속 적용된다 (#75).",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders",
"mergeQuery": true,
"query": {
"orderer_uuid": "{{row.user?.uuid}}",
"search_field": "",
"search_keyword": "",
"page": 1
},
"transition_overlay_target": "order_datagrid__body"
@@ -617,16 +623,20 @@
"params": {
"target": "_local",
"filter.searchField": "orderer_name",
"filter.searchKeyword": "{{row.address?.orderer_name || row.user?.name}}"
"filter.searchKeyword": "{{row.address?.orderer_name || row.user?.name}}",
"filter.ordererUuid": ""
}
},
{
"comment": "비회원 주문자명으로 검색 — 회원 지정 필터(orderer_uuid)를 함께 비우지 않으면 이름 검색과 회원 필터가 겹쳐 결과가 비어 보인다 (#75).",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders",
"mergeQuery": true,
"query": {
"search_field": "orderer_name",
"search_keyword": "{{row.address?.orderer_name || row.user?.name}}",
"orderer_uuid": "",
"page": 1
},
"transition_overlay_target": "order_datagrid__body"
@@ -762,11 +772,11 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders",
"replace": true,
"mergeQuery": true,
"query": {
"page": "{{$args[0]}}"
},
"replace": true,
"transition_overlay_target": "order_datagrid__body"
}
},
@@ -778,7 +788,9 @@
"view_detail": {
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders/{{$args[1].order_number}}"
"path": "/admin/ecommerce/orders/{{$args[1].order_number}}",
"mergeQuery": true,
"query": {}
}
}
}
@@ -86,6 +86,7 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 프리셋 적용 — 프리셋은 조건 세트를 통째로 갈아끼우는 것이므로 병합하면 직전 프리셋의 조건(기간·상태 등)이 남는다.",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders",
@@ -136,6 +137,7 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 프리셋 적용 — 프리셋은 조건 세트를 통째로 갈아끼우는 것이므로 병합하면 직전 프리셋의 조건(기간·상태 등)이 남는다.",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders",
@@ -188,6 +190,7 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 저장된 프리셋 적용 — 저장해 둔 조건 세트를 통째로 적용하는 것이 목적이므로 병합하면 직전 조건이 섞인다.",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders",
@@ -244,6 +247,7 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 저장된 프리셋 적용 — 저장해 둔 조건 세트를 통째로 적용하는 것이 목적이므로 병합하면 직전 조건이 섞인다.",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/orders",
@@ -241,8 +241,10 @@
}
},
{
"comment": "선택 상태만 주소에 기록한다 — 왼쪽 목록에 걸어둔 검색어는 그대로 둔다 (#75).",
"handler": "replaceUrl",
"params": {
"mergeQuery": true,
"query": {
"id": "{{item.id}}",
"mode": "view"
@@ -271,7 +271,9 @@
{
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/products"
"path": "/admin/ecommerce/products",
"mergeQuery": true,
"query": {}
}
}
],
@@ -588,10 +588,11 @@
"text": "$t:sirsoft-ecommerce.admin.product.buttons.create_shipping_policy",
"actions": [
{
"comment": "audit:allow layout-list-context-navigate-merge-query 상품 폼에서 배송정책 목록 클러스터로 나가는 이동이다 — 상품 목록의 페이지·필터를 물려주지 않는다.",
"type": "click",
"handler": "navigate",
"params": {
"url": "/admin/ecommerce/shipping-policies/create"
"path": "/admin/ecommerce/shipping-policies/create"
}
}
]
@@ -189,10 +189,11 @@
},
"actions": [
{
"comment": "audit:allow layout-list-context-navigate-merge-query 상품 폼에서 채널 연동 설정 화면으로 나가는 이동이다 — 상품 목록의 페이지·필터를 물려주지 않는다.",
"type": "click",
"handler": "navigate",
"params": {
"url": "/admin/ecommerce/integrations/naver"
"path": "/admin/ecommerce/integrations/naver"
}
}
],
@@ -546,10 +547,11 @@
},
"actions": [
{
"comment": "audit:allow layout-list-context-navigate-merge-query 상품 폼에서 채널 연동 설정 화면으로 나가는 이동이다 — 상품 목록의 페이지·필터를 물려주지 않는다.",
"type": "click",
"handler": "navigate",
"params": {
"url": "/admin/ecommerce/integrations/facebook"
"path": "/admin/ecommerce/integrations/facebook"
}
}
],
@@ -903,10 +905,11 @@
},
"actions": [
{
"comment": "audit:allow layout-list-context-navigate-merge-query 상품 폼에서 채널 연동 설정 화면으로 나가는 이동이다 — 상품 목록의 페이지·필터를 물려주지 않는다.",
"type": "click",
"handler": "navigate",
"params": {
"url": "/admin/ecommerce/integrations/google"
"path": "/admin/ecommerce/integrations/google"
}
}
],
@@ -1702,6 +1702,7 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 프리셋 적용 — 프리셋은 조건 세트를 통째로 갈아끼우는 것이므로 병합하면 직전 조건이 섞인다. 주문 목록의 프리셋 규약과 동일.",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/products",
@@ -2287,6 +2288,7 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 필터 초기화 — 걸려 있던 조건을 전부 비우는 것이 이 버튼의 목적이므로 병합하지 않는다.",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/products",
@@ -1565,7 +1565,9 @@
"edit": {
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/products/{{$args[1].product_code ?? $args[1].id}}/edit"
"path": "/admin/ecommerce/products/{{$args[1].product_code ?? $args[1].id}}/edit",
"mergeQuery": true,
"query": {}
}
},
"copy": {
@@ -215,8 +215,10 @@
}
},
{
"comment": "선택 상태만 주소에 기록한다 — 왼쪽 목록에 걸어둔 검색어는 그대로 둔다 (#75).",
"handler": "replaceUrl",
"params": {
"mergeQuery": true,
"query": {
"id": "{{template.id}}",
"mode": "view"
@@ -163,13 +163,13 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/promotion-coupons",
"replace": true,
"mergeQuery": true,
"query": {
"sort_by": "{{$event.target.value.split('_').slice(0, -1).join('_')}}",
"sort_order": "{{$event.target.value.split('_').pop()}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "pc_data_table__body"
}
}
@@ -207,12 +207,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/promotion-coupons",
"replace": true,
"mergeQuery": true,
"query": {
"per_page": "{{$event.target.value}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "pc_data_table__body"
}
}
@@ -311,7 +311,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/promotion-coupons/{{row.id}}/edit"
"path": "/admin/ecommerce/promotion-coupons/{{row.id}}/edit",
"mergeQuery": true,
"query": {}
}
}
]
@@ -563,11 +565,12 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/promotion-coupons",
"replace": true,
"mergeQuery": true,
"query": {
"created_by": "{{row.created_by}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "pc_data_table__body"
}
}
@@ -588,11 +591,11 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/promotion-coupons",
"replace": true,
"mergeQuery": true,
"query": {
"page": "{{$args[0]}}"
},
"replace": true,
"transition_overlay_target": "pc_data_table__body"
}
},
@@ -611,7 +614,9 @@
"edit": {
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/promotion-coupons/{{$args[1].id}}/edit"
"path": "/admin/ecommerce/promotion-coupons/{{$args[1].id}}/edit",
"mergeQuery": true,
"query": {}
}
},
"issue_history": {
@@ -817,7 +822,9 @@
"edit": {
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/promotion-coupons/{{coupon.id}}/edit"
"path": "/admin/ecommerce/promotion-coupons/{{coupon.id}}/edit",
"mergeQuery": true,
"query": {}
}
},
"issue_history": {
@@ -2241,9 +2241,11 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 필터 초기화 — 걸려 있던 조건을 전부 비우는 것이 이 버튼의 목적이므로 병합하지 않는다.",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/promotion-coupons",
"mergeQuery": false,
"replace": true
}
}
@@ -2366,9 +2368,11 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 필터 초기화 — 걸려 있던 조건을 전부 비우는 것이 이 버튼의 목적이므로 병합하지 않는다.",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/promotion-coupons",
"mergeQuery": false,
"replace": true
}
}
@@ -308,11 +308,13 @@
},
"actions": [
{
"comment": "audit:allow layout-list-context-navigate-merge-query 필터 초기화 — 걸려 있던 조건을 전부 비우는 것이 이 버튼의 목적이므로 병합하지 않는다.",
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/settings",
"replace": true,
"mergeQuery": false,
"query": {
"tab": "identity_policies"
},
@@ -255,6 +255,7 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 탭 전환 — 탭마다 다루는 대상이 달라 이전 탭의 목록 상태를 물려받으면 오히려 잘못된 화면이 된다.",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/settings",
@@ -247,6 +247,7 @@
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/shipping-policies/create",
"mergeQuery": true,
"query": {
"copy_id": "{{_global.targetPolicy?.id}}"
}
@@ -346,7 +346,9 @@
"edit": {
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/shipping-policies/{{$args[1].id}}/edit"
"path": "/admin/ecommerce/shipping-policies/{{$args[1].id}}/edit",
"mergeQuery": true,
"query": {}
}
},
"copy": {
@@ -1098,9 +1098,11 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 필터 초기화 — 걸려 있던 조건을 전부 비우는 것이 이 버튼의 목적이므로 병합하지 않는다.",
"handler": "navigate",
"params": {
"path": "/admin/ecommerce/shipping-policies"
"path": "/admin/ecommerce/shipping-policies",
"mergeQuery": false
}
}
]
@@ -3,6 +3,8 @@
namespace Modules\Sirsoft\Ecommerce\Repositories;
use App\Helpers\PermissionHelper;
use App\Search\Engines\DatabaseFulltextEngine;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Modules\Sirsoft\Ecommerce\Models\Brand;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\BrandRepositoryInterface;
@@ -34,39 +36,21 @@ class BrandRepository implements BrandRepositoryInterface
$query->where('is_active', $filters['is_active']);
}
// 검색 키워드가 있으면 Scout 사용
// 검색 키워드 — 이름(FULLTEXT) / slug / website 중 하나만 맞아도 나와야 한다.
//
// Scout 의 `search()->query(fn)` 로는 이 형태를 만들 수 없다. 그 콜백은 검색 엔진이
// 이미 맞힌 결과를 **좁히는** 자리라, slug·website 를 OR 로 덧붙여도 실제로는 AND 로
// 걸린다. 이름만 일치하고 slug·website 에는 키워드가 없는 브랜드(대부분)가 전부
// 걸러져 검색 결과가 항상 비었다. 같은 함정을 이미 겪은 페이지 검색과 동일하게
// whereFulltext 헬퍼로 한 쿼리 안에서 OR 로 묶는다.
if (! empty($filters['search'])) {
$keyword = $filters['search'];
return Brand::search($keyword)
->query(function ($query) use ($filters, $keyword, $with) {
// 권한 스코프 필터링
PermissionHelper::applyPermissionScope($query, 'sirsoft-ecommerce.brands.read');
// 상품 수 조회
$query->withCount('products');
// 활성 상태 필터
if (isset($filters['is_active'])) {
$query->where('is_active', $filters['is_active']);
}
// FULLTEXT 외 필드 OR 조건 (slug, website)
$query->where(function ($q) use ($keyword) {
$q->orWhere('slug', 'like', "%{$keyword}%")
->orWhere('website', 'like', "%{$keyword}%");
});
// 정렬 처리
$locale = $filters['locale'] ?? app()->getLocale();
$this->applySorting($query, $filters, $locale);
// Eager loading
if (! empty($with)) {
$query->with($with);
}
})
->get();
$query->where(function ($q) use ($keyword) {
DatabaseFulltextEngine::whereFulltext($q, 'name', $keyword, 'and');
$q->orWhere('slug', 'like', "%{$keyword}%")
->orWhere('website', 'like', "%{$keyword}%");
});
}
// 정렬 처리
@@ -88,7 +72,7 @@ class BrandRepository implements BrandRepositoryInterface
{
$query = $this->model->newQuery();
if (!empty($with)) {
if (! empty($with)) {
$query->with($with);
}
@@ -113,6 +97,7 @@ class BrandRepository implements BrandRepositoryInterface
{
$brand = $this->findById($id);
$brand->update($data);
return $brand->fresh();
}
@@ -122,6 +107,7 @@ class BrandRepository implements BrandRepositoryInterface
public function delete(int $id): bool
{
$brand = $this->findById($id);
return $brand->delete();
}
@@ -131,6 +117,7 @@ class BrandRepository implements BrandRepositoryInterface
public function getProductCount(int $id): int
{
$brand = $this->findById($id);
return $brand->products()->count();
}
@@ -151,10 +138,9 @@ class BrandRepository implements BrandRepositoryInterface
/**
* 정렬 조건을 쿼리에 적용합니다.
*
* @param \Illuminate\Database\Eloquent\Builder $query Eloquent 쿼리 빌더
* @param array $filters 필터 배열
* @param string $locale 로케일
* @return void
* @param Builder $query Eloquent 쿼리 빌더
* @param array $filters 필터 배열
* @param string $locale 로케일
*/
private function applySorting($query, array $filters, string $locale): void
{
@@ -190,4 +176,4 @@ class BrandRepository implements BrandRepositoryInterface
$query->orderBy("name->{$locale}")->orderBy('id');
}
}
}
}
@@ -0,0 +1,73 @@
/**
* 관리자 주문 목록 컨텍스트 왕복 — 걸어 둔 필터·페이지가 상세를 다녀와도 살아남는지 (공개 이슈 #75).
*
* 결함: 주문 목록에서 주문 하나를 열었다가 '목록' 으로 돌아오면 걸어 둔 필터와 페이지가 전부 사라져
* 1페이지 무필터 상태로 되돌아갔다. 필터를 여러 개 걸어 두고 주문을 하나씩 확인하는 실제 운영 흐름에서
* 가장 아프게 드러나는 형태다.
*
* 표식으로 `page` 와 `per_page` 를 쓴다 — 시드 주문 105건이므로 2페이지가 항상 존재한다.
*
* 커버 범위: 이 spec 은 **복귀 leg**(상세 → 목록)를 브라우저 수준에서 실측한다. 진입 leg 는
* `resources/js/__tests__/layouts/admin-list-context-round-trip.test.ts` 가 실 JSON 의 액션 params 로
* 고정하고 audit 룰 layout-list-context-navigate-merge-query 가 재발을 차단한다.
*
* @scenario order-admin-list-context-round-trip
* @axes leg=back
* @effects list_return_preserves_query
*/
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
const LIST_URL = '/admin/ecommerce/orders';
const LIST_STATE = 'page=2&per_page=10';
/**
* 현재 URL 의 쿼리 파라미터를 돌려줍니다.
*
* @param url 페이지 URL
* @returns 쿼리 파라미터
*/
function queryOf(url: string): URLSearchParams {
return new URL(url).searchParams;
}
test.describe('관리자 주문 목록 컨텍스트 왕복 (#75)', () => {
// @scenario leg=back
// @effects list_return_preserves_query
test('주문 상세에서 목록으로 돌아오면 페이지와 표시 개수가 복원된다', async ({ page, ordersReadToken }) => {
await authenticatePage(page, ordersReadToken);
await page.goto(`${LIST_URL}?${LIST_STATE}`);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
// 목록에서 상세로 들어간 직후와 동일한 URL 상태 (진입 leg 가 mergeQuery 로 만들어 주는 형태)
const orderNumber = await page.evaluate(async () => {
const res = await fetch('/api/modules/sirsoft-ecommerce/admin/orders?per_page=10', {
headers: {
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
Accept: 'application/json',
},
});
const json = await res.json();
// 응답 봉투 형태에 의존하지 않도록 order_number 를 가진 첫 객체를 찾는다.
const stack: unknown[] = [json];
while (stack.length > 0) {
const node = stack.pop();
if (!node || typeof node !== 'object') continue;
const rec = node as Record<string, unknown>;
if (typeof rec.order_number === 'string') return rec.order_number;
stack.push(...Object.values(rec));
}
return null;
});
expect(orderNumber, '주문 시드가 없어 왕복을 검증할 수 없습니다.').not.toBeNull();
await page.goto(`${LIST_URL}/${orderNumber}?${LIST_STATE}`);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
await page.locator('#list_button').click();
await page.waitForURL(/\/admin\/ecommerce\/orders\?/, { timeout: 30_000 });
const q = queryOf(page.url());
expect(q.get('page')).toBe('2');
expect(q.get('per_page')).toBe('10');
});
});
@@ -0,0 +1,147 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Repositories;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Ecommerce\Models\Brand;
use Modules\Sirsoft\Ecommerce\Repositories\BrandRepository;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 브랜드 Repository 검색 회귀 테스트
*
* 결함: 브랜드명으로 검색하면 결과가 항상 0건이었다. FULLTEXT 로 이름을 맞힌 뒤
* Scout 의 query 콜백 안에서 `slug`/`website` 를 OR 로 덧붙여 검색 범위를 넓히려 했으나,
* 그 콜백은 이미 맞힌 결과를 **좁히는** 자리라 실제로는 AND 로 걸렸다. 이름만 일치하고
* slug·website 에는 키워드가 없는 브랜드(대부분)가 전부 걸러졌다.
*
* 검색은 세 필드 중 하나라도 맞으면 나와야 한다 — 이름 / slug / 웹사이트.
*/
class BrandRepositorySearchTest extends ModuleTestCase
{
protected BrandRepository $repository;
protected function setUp(): void
{
parent::setUp();
$this->repository = new BrandRepository(new Brand);
}
/**
* 테스트용 브랜드를 만듭니다.
*
* @param array<string, string> $name 로케일별 이름
* @param string $slug slug
* @param string|null $website 웹사이트 주소
* @return Brand 생성된 브랜드
*/
private function makeBrand(array $name, string $slug, ?string $website = null): Brand
{
return Brand::create([
'name' => $name,
'slug' => $slug,
'website' => $website,
'is_active' => true,
'sort_order' => 1,
]);
}
/**
* 이름 검색은 결과 행이 아니라 실행된 SQL 형태로 단언한다.
*
* InnoDB FULLTEXT 색인은 커밋 이후에만 보이므로, 트랜잭션 안에서 만든 행은 MATCH 로
* 잡히지 않는다 (코어 DatabaseFulltextEngineTest 도 같은 이유로 SQL 을 단언한다).
* 이번 결함의 본질은 "이름 조건이 slug·website 와 OR 로 묶이는가" 이므로 그 형태를 고정한다.
*/
public function test_search_combines_name_slug_website_with_or(): void
{
$this->makeBrand(['ko' => '나이키', 'en' => 'Nike'], 'nike', 'https://www.nike.com');
DB::enableQueryLog();
$this->repository->getAll(['search' => '나이키']);
$log = DB::getQueryLog();
DB::disableQueryLog();
$sql = collect($log)->pluck('query')->implode(' | ');
$this->assertStringContainsString(
'match(`name`) against',
strtolower($sql),
'이름에 대한 FULLTEXT 조건이 쿼리에 없습니다.'
);
$this->assertMatchesRegularExpression(
'/against\(\? in boolean mode\)\s+or\s+`slug`\s+like\s+\?\s+or\s+`website`\s+like\s+\?/i',
strtolower($sql),
'이름·slug·website 가 OR 로 묶이지 않았습니다 — 좁히는 AND 로 걸리면 이름만 일치하는 브랜드가 전부 사라집니다.'
);
}
public function test_search_finds_brand_by_slug(): void
{
// Given: 이름은 한글, slug 는 영문
$puma = $this->makeBrand(['ko' => '퓨마', 'en' => 'Puma'], 'puma-korea', 'https://www.puma.com');
// When: slug 조각으로 검색
$result = $this->repository->getAll(['search' => 'puma-korea']);
// Then: slug 가 맞았으므로 나와야 한다
$this->assertCount(1, $result, 'slug 가 일치하는 브랜드가 검색되지 않았습니다.');
$this->assertSame($puma->id, $result->first()->id);
}
public function test_search_finds_brand_by_website(): void
{
// Given: 이름·slug 와 무관한 도메인
$zara = $this->makeBrand(['ko' => '자라', 'en' => 'ZARA'], 'zara', 'https://shop.inditex-group.com');
// When: 도메인 조각으로 검색
$result = $this->repository->getAll(['search' => 'inditex-group']);
// Then: website 가 맞았으므로 나와야 한다
$this->assertCount(1, $result, 'website 가 일치하는 브랜드가 검색되지 않았습니다.');
$this->assertSame($zara->id, $result->first()->id);
}
public function test_search_excludes_unrelated_brands(): void
{
// Given: 서로 겹치지 않는 두 브랜드
$this->makeBrand(['ko' => '나이키', 'en' => 'Nike'], 'nike', 'https://www.nike.com');
$this->makeBrand(['ko' => '아디다스', 'en' => 'Adidas'], 'adidas', 'https://www.adidas.com');
// When: 한쪽만 가리키는 키워드로 검색
$result = $this->repository->getAll(['search' => 'adidas']);
// Then: 나머지는 섞이지 않는다
$this->assertCount(1, $result);
$this->assertSame('adidas', $result->first()->slug);
}
public function test_search_respects_active_filter(): void
{
// Given: 같은 키워드를 가진 활성/비활성 브랜드
$active = $this->makeBrand(['ko' => '나이키', 'en' => 'Nike'], 'nike-korea', null);
$inactive = $this->makeBrand(['ko' => '나이키 키즈', 'en' => 'Nike Kids'], 'nike-kids', null);
$inactive->update(['is_active' => false]);
// When: 활성만 요청
$result = $this->repository->getAll(['search' => 'nike-korea', 'is_active' => true]);
// Then: 검색과 필터가 함께 적용된다
$this->assertCount(1, $result);
$this->assertSame($active->id, $result->first()->id);
}
public function test_search_keeps_active_filter_and_keyword_grouped(): void
{
// Given: 비활성 브랜드가 키워드를 담고 있다
$this->makeBrand(['ko' => '나이키', 'en' => 'Nike'], 'nike-korea', null)
->update(['is_active' => false]);
// When: 활성만 요청
$result = $this->repository->getAll(['search' => 'nike-korea', 'is_active' => true]);
// Then: OR 그룹이 괄호로 묶여야 활성 조건이 무력화되지 않는다
$this->assertCount(0, $result, 'is_active 조건이 OR 로 흡수돼 비활성 브랜드가 새어 나왔습니다.');
}
}
@@ -0,0 +1,31 @@
feature: 관리자 주문 목록 컨텍스트 왕복
description: |
관리자 주문 목록과 그에 딸린 주문 상세는 하나의 "목록 클러스터" 다. 이 안에서의 이동은 URL 목록
상태(page / per_page / 검색 / 상태 필터 / 기간)를 손실 없이 이어받아야 한다 (공개 이슈 #75).
운영 흐름상 이 도메인이 가장 아프다 — 필터를 여러 개 걸어 두고 주문을 하나씩 열어 확인하는데,
복귀할 때마다 1페이지 무필터로 되돌아가면 걸어 둔 조건을 매번 다시 만들어야 한다.
반대로 **초기화 버튼과 프리셋 적용은 병합하지 않는다**. 프리셋은 조건 세트를 통째로 갈아끼우는
동작이라 병합하면 직전 프리셋의 기간·상태가 남아 엉뚱한 결과가 나온다. 이 구분은 액션 노드의
`audit:allow layout-list-context-navigate-merge-query` 주석으로 코드에 기록돼 있고,
룰이 "주석은 병합하지 않겠다는데 코드는 mergeQuery: true" 인 자기모순을 검출한다.
axes:
# 목록 클러스터 안에서 발생하는 이동 leg
leg: [back] # 주문 상세 → 목록
effects:
- list_return_preserves_query # 복귀 이동이 URL 목록 상태를 그대로 복원
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/order-admin-list-context-round-trip.spec.ts
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-list-context-round-trip.test.ts
- .claude/scripts/audit/__tests__/layout-list-context-navigate-merge-query.test.cjs
validation:
note: |
진입 leg(목록 행 → 상세)와 필터/프리셋/초기화의 병합 여부는 레이아웃 테스트가 실 JSON 의 액션
params 로 전수 고정한다. 브라우저 spec 은 진입이 만들어 내는 URL 상태를 재현한 뒤 복귀 leg 를
실측한다. 재발 차단은 audit 룰 `layout-list-context-navigate-merge-query` (error) 가 담당한다.
@@ -18,6 +18,7 @@
### Fixed
- 관리자 페이지 목록에서 상세나 수정 화면에 다녀오면 보고 있던 페이지와 검색 조건이 사라지고 1페이지로 되돌아가던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 페이지 활동 로그에서 "제목"·"내용" 변경 항목의 이름이 비어 보이던 문제를 수정했습니다.
- 다른 페이지의 주소로 특정 페이지의 이전 버전 내용을 열람할 수 있던 문제를 수정했습니다.
- 다른 페이지에 속한 버전으로 복원을 시도하면 "복원에 실패했습니다" 라는 서버 오류가 나던 문제를 수정했습니다. 이제 열람과 동일하게 "찾을 수 없음" 으로 안내합니다.
@@ -0,0 +1,212 @@
/**
* @file admin-list-context-round-trip.test.ts
* @description 관리자 페이지 목록 컨텍스트 왕복 시 URL 목록 상태 보존 회귀 (#75)
*
* 결함: 페이지 목록(`/admin/pages`) 에서 상세·수정 폼으로 들어갔다 돌아오면 navigate 가
* `mergeQuery` 없이 실행되어 page/검색 필터가 URL 에서 사라지고 1페이지로 되돌아갔다.
*
* 검증 방식 2단:
* 1. 실 레이아웃 JSON 전수 분석 — 클러스터 내 navigate 가 모두 `mergeQuery: true` 인지
* 2. 실 액션을 엔진에 태워 실행(`triggerAction`) — 선언이 실제 목적지 URL 로 이어지는지
* 선언만 검사하면 "JSON 에 리터럴이 적혀 있다" 까지만 고정되고 병합 결과는 미검증으로 남는다.
*
* @scenario leg=row_click|detail_return|form_cancel
* @effects navigation_preserves_list_query
*
* @vitest-environment jsdom
*/
import { describe, it, expect, afterEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import {
createLayoutTest,
createMockComponentRegistryWithBasics,
} from '@core/template-engine/__tests__/utils/layoutTestUtils';
const LAYOUTS_ROOT = path.resolve(__dirname, '../../../layouts/admin');
/** 이 확장이 소유한 페이지네이션 목록 라우트 */
const LIST_ROUTES = ['/admin/pages'];
/** 목록 클러스터에 속하는 레이아웃 파일 */
const CLUSTER_FILES = ['admin_page_list.json', 'admin_page_detail.json', 'admin_page_form.json'];
type NavAction = {
handler: string;
comment?: string;
params: { path?: string; mergeQuery?: unknown; query?: unknown };
};
/**
* JSON 트리에서 navigate/replaceUrl 액션을 모두 수집합니다.
*
* @param node 순회 시작 노드
* @returns 액션 목록
*/
function collectNavActions(node: unknown): NavAction[] {
const out: NavAction[] = [];
const walk = (n: unknown): void => {
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
if (!n || typeof n !== 'object') return;
const obj = n as Record<string, unknown>;
if (
(obj.handler === 'navigate' || obj.handler === 'replaceUrl') &&
obj.params &&
typeof obj.params === 'object'
) {
out.push(obj as unknown as NavAction);
}
Object.values(obj).forEach(walk);
};
walk(node);
return out;
}
/**
* 목적지 경로를 비교용 표준형(`{{expr}}` → `*`)으로 바꿉니다.
*
* @param raw 원본 경로
* @returns 표준형 경로 (판정 불가 시 null)
*/
function normalizePath(raw: unknown): string | null {
if (typeof raw !== 'string' || raw === '') return null;
const p = raw.replace(/\{\{[^}]*\}\}/g, '*').split('?')[0].replace(/\/+$/, '');
return p.startsWith('/') ? p : null;
}
/**
* 경로가 목록 라우트와 같은 클러스터인지 판정합니다.
*
* @param dest 표준형 목적지
* @param list 표준형 목록 라우트
* @returns 같은 클러스터 여부
*/
function inFamily(dest: string, list: string): boolean {
return dest === list || dest.startsWith(`${list}/`);
}
/**
* 레이아웃 파일을 읽어 파싱합니다.
*
* @param rel 레이아웃 루트 기준 상대 경로
* @returns 파싱된 JSON
*/
function readLayout(rel: string): unknown {
return JSON.parse(fs.readFileSync(path.join(LAYOUTS_ROOT, rel), 'utf8'));
}
describe('#75 관리자 페이지 목록 컨텍스트 왕복 — mergeQuery 전수', () => {
it.each(CLUSTER_FILES)('%s 의 클러스터 내 navigate 는 모두 mergeQuery: true', (rel) => {
const offenders = collectNavActions(readLayout(rel))
.filter((a) => {
if (typeof a.comment === 'string' && a.comment.includes('audit:allow')) return false;
const dest = normalizePath(a.params.path);
if (!dest) return false;
if (!LIST_ROUTES.some((l) => inFamily(dest, l))) return false;
return a.params.mergeQuery !== true;
})
.map((a) => `${a.params.path} (mergeQuery=${JSON.stringify(a.params.mergeQuery)})`);
expect(offenders, `목록 상태를 떨구는 navigate: \n ${offenders.join('\n ')}`).toEqual([]);
});
it("페이지 상세의 '목록' 버튼이 목록 상태를 복원한다", () => {
const backs = collectNavActions(readLayout('admin_page_detail.json')).filter(
(a) => a.params.path === '/admin/pages'
);
expect(backs.length).toBeGreaterThan(0);
backs.forEach((a) => expect(a.params.mergeQuery).toBe(true));
});
});
// ---------------------------------------------------------------------------
// 실행 검증 — 실 액션을 엔진에 태워 목적지 URL 을 확인한다
// ---------------------------------------------------------------------------
/**
* 실 JSON 액션을 현재 URL 상태에서 실행하고 목적지를 돌려줍니다.
*
* @param action 실 레이아웃 JSON 에서 뽑은 navigate 액션
* @returns navigate 목적지 경로
*/
async function runNav(action: NavAction): Promise<string> {
const utils = createLayoutTest(
{
version: '1.0.0',
layout_name: 'nav_action_probe',
data_sources: [],
components: [{ type: 'basic', name: 'Div', props: { 'data-testid': 'probe' } }],
} as never,
{ componentRegistry: createMockComponentRegistryWithBasics() as never, locale: 'ko' }
);
await utils.render();
await utils.triggerAction({ type: 'click', ...action } as never);
const dest = utils.getNavigationHistory()[0];
utils.cleanup();
return dest;
}
/**
* 목적지 URL 의 쿼리스트링을 파싱합니다.
*
* @param dest navigate 목적지
* @returns URLSearchParams
*/
function queryOf(dest: string): URLSearchParams {
return new URLSearchParams(dest.includes('?') ? dest.slice(dest.indexOf('?')) : '');
}
/**
* 실 JSON 에서 목적지 경로가 일치하는 navigate 액션을 찾습니다.
*
* @param json 레이아웃 JSON
* @param destPath 목적지 경로 (원문 그대로)
* @returns 액션
*/
function findNavAction(json: unknown, destPath: string): NavAction {
const action = collectNavActions(json).find((a) => a.params.path === destPath);
expect(action, `navigate 액션(${destPath})을 실 JSON 에서 찾지 못함`).toBeTruthy();
return action as NavAction;
}
describe('#75 관리자 페이지 목록 컨텍스트 왕복 — 실행 결과 URL', () => {
afterEach(() => {
window.history.replaceState({}, '', '/');
});
it('목록 행 클릭이 목록 상태를 상세 URL 로 나른다', async () => {
window.history.replaceState({}, '', '/admin/pages?page=3&per_page=2&sort_by=title');
const q = queryOf(await runNav(findNavAction(readLayout('admin_page_list.json'), '/admin/pages/{{$args[1].id}}')));
expect(q.get('page')).toBe('3');
expect(q.get('per_page')).toBe('2');
expect(q.get('sort_by')).toBe('title');
});
it("상세의 '목록' 버튼이 상세 URL 의 목록 상태를 목록으로 복귀시킨다", async () => {
window.history.replaceState({}, '', '/admin/pages/7?page=3&per_page=2&sort_by=title');
const q = queryOf(await runNav(findNavAction(readLayout('admin_page_detail.json'), '/admin/pages')));
expect(q.get('page')).toBe('3');
expect(q.get('per_page')).toBe('2');
expect(q.get('sort_by')).toBe('title');
});
it('상세 → 수정 이동에서도 목록 상태가 이어진다', async () => {
window.history.replaceState({}, '', '/admin/pages/7?page=3&per_page=2');
const q = queryOf(
await runNav(findNavAction(readLayout('admin_page_detail.json'), '/admin/pages/{{route?.id}}/edit'))
);
expect(q.get('page')).toBe('3');
expect(q.get('per_page')).toBe('2');
});
});
@@ -158,7 +158,9 @@
{
"handler": "navigate",
"params": {
"path": "/admin/pages"
"path": "/admin/pages",
"mergeQuery": true,
"query": {}
}
}
],
@@ -655,7 +657,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/pages"
"path": "/admin/pages",
"mergeQuery": true,
"query": {}
}
}
],
@@ -691,7 +695,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/pages/{{route?.id}}/edit"
"path": "/admin/pages/{{route?.id}}/edit",
"mergeQuery": true,
"query": {}
}
}
],
@@ -963,7 +963,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "{{route?.id ? '/admin/pages/' + route.id : '/admin/pages'}}"
"path": "{{route?.id ? '/admin/pages/' + route.id : '/admin/pages'}}",
"mergeQuery": true,
"query": {}
}
}
],
@@ -1058,7 +1060,9 @@
{
"handler": "navigate",
"params": {
"path": "/admin/pages/{{route?.id ?? response.data.id}}"
"path": "/admin/pages/{{route?.id ?? response.data.id}}",
"mergeQuery": true,
"query": {}
}
}
],
@@ -111,7 +111,8 @@
"handler": "navigate",
"params": {
"path": "/admin/pages",
"mergeQuery": false
"mergeQuery": true,
"query": {}
}
}
],
@@ -139,7 +140,9 @@
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/pages/create"
"path": "/admin/pages/create",
"mergeQuery": true,
"query": {}
}
}
],
@@ -828,13 +831,17 @@
"view": {
"handler": "navigate",
"params": {
"path": "/admin/pages/{{$args[1].id}}"
"path": "/admin/pages/{{$args[1].id}}",
"mergeQuery": true,
"query": {}
}
},
"edit": {
"handler": "navigate",
"params": {
"path": "/admin/pages/{{$args[1].id}}/edit"
"path": "/admin/pages/{{$args[1].id}}/edit",
"mergeQuery": true,
"query": {}
}
},
"delete": {
@@ -968,7 +975,7 @@
"params": {
"path": "/admin/pages",
"mergeQuery": true,
"query": []
"query": {}
}
},
{
@@ -1129,7 +1136,7 @@
"params": {
"path": "/admin/pages",
"mergeQuery": true,
"query": []
"query": {}
}
},
{
@@ -0,0 +1,106 @@
/**
* 관리자 페이지 목록 컨텍스트 왕복 — 목록 상태가 상세·폼을 거쳐도 살아남는지 (공개 이슈 #75).
*
* 결함: 목록 → 상세 → 목록, 목록 → 등록/수정 폼 → 취소 경로에 `mergeQuery` 가 빠져 있어 표시 개수·
* 필터 같은 목록 상태가 이동 한 번에 사라졌다. 취소 버튼은 목적지가 삼항 표현식이라 정적 검사에서도
* 오래 빠져 있던 자리다.
*
* 표시 개수(`per_page`)를 표식으로 쓴다 — 시드 건수와 무관하게 항상 URL 에 실리는 목록 상태다.
*
* 커버 범위: 이 spec 은 **복귀 leg**(상세 → 목록, 폼 취소 → 목록)를 브라우저 수준에서 실측한다.
* 진입 leg(목록 행 → 상세)는 행 끝 ⋯ 메뉴가 포털로 렌더되어 로케이터가 불안정하므로,
* `resources/js/__tests__/layouts/admin-list-context-round-trip.test.ts` 가 실 JSON 의 액션 params 로
* 고정하고 audit 룰이 재발을 차단한다. 복귀 leg 는 진입으로 만들어진 URL 상태를 그대로 재현해 검증한다.
*
* @scenario page-admin-list-context-round-trip
* @axes leg=back leg=cancel
* @effects list_return_preserves_query
*/
import { test, expect, authenticatePage } from '../../fixtures/page-auth';
const LIST_URL = '/admin/pages';
const LIST_STATE = 'per_page=10';
/**
* 현재 URL 의 쿼리 파라미터를 돌려줍니다.
*
* @param url 페이지 URL
* @returns 쿼리 파라미터
*/
function queryOf(url: string): URLSearchParams {
return new URL(url).searchParams;
}
/**
* 목록의 첫 페이지 id 를 조회합니다.
*
* 브라우저 컨텍스트에서 조회해 화면과 같은 토큰·권한을 그대로 쓴다.
*
* @param page Playwright 페이지
* @returns 페이지 id
*/
async function firstPageId(page: import('@playwright/test').Page): Promise<number> {
const id = await page.evaluate(async () => {
const res = await fetch('/api/modules/sirsoft-page/admin/pages?per_page=1', {
headers: {
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
Accept: 'application/json',
},
});
const json = await res.json();
const rows = json?.data?.data ?? json?.data ?? [];
return Array.isArray(rows) && rows.length > 0 ? rows[0].id : null;
});
expect(id, '페이지 시드가 없어 왕복을 검증할 수 없습니다.').not.toBeNull();
return id as number;
}
test.describe('관리자 페이지 목록 컨텍스트 왕복 (#75)', () => {
// @scenario leg=back
// @effects list_return_preserves_query
test('상세에서 목록으로 돌아오면 목록 상태가 복원된다', async ({ page, pageManageToken }) => {
await authenticatePage(page, pageManageToken);
await page.goto(`${LIST_URL}?${LIST_STATE}`);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
const id = await firstPageId(page);
// 목록에서 상세로 들어간 직후와 동일한 URL 상태 (진입 leg 가 mergeQuery 로 만들어 주는 형태)
await page.goto(`${LIST_URL}/${id}?${LIST_STATE}`);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
await page.locator('#header_back_button').click();
await page.waitForURL(/\/admin\/pages\?/, { timeout: 30_000 });
expect(queryOf(page.url()).get('per_page')).toBe('10');
});
// @scenario leg=cancel
// @effects list_return_preserves_query
test('등록 폼에서 취소하면 목록 상태가 유지된 채 목록으로 돌아온다', async ({ page, pageManageToken }) => {
await authenticatePage(page, pageManageToken);
await page.goto(`${LIST_URL}/create?${LIST_STATE}`);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
await page.locator('#footer_cancel_button').click();
await page.waitForURL(/\/admin\/pages\?/, { timeout: 30_000 });
expect(queryOf(page.url()).get('per_page')).toBe('10');
});
// @scenario leg=cancel
// @effects list_return_preserves_query
test('수정 폼에서 취소하면 그 페이지 상세로 돌아오며 목록 상태가 살아 있다', async ({ page, pageManageToken }) => {
await authenticatePage(page, pageManageToken);
await page.goto(`${LIST_URL}?${LIST_STATE}`);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
const id = await firstPageId(page);
await page.goto(`${LIST_URL}/${id}/edit?${LIST_STATE}`);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
await page.locator('#footer_cancel_button').click();
await page.waitForURL(new RegExp(`/admin/pages/${id}\\?`), { timeout: 30_000 });
expect(queryOf(page.url()).get('per_page')).toBe('10');
});
});
@@ -0,0 +1,29 @@
feature: 관리자 페이지 목록 컨텍스트 왕복
description: |
관리자 페이지 목록과 그에 딸린 상세·등록/수정 폼은 하나의 "목록 클러스터" 다. 이 안에서의 이동은
URL 목록 상태(page / per_page / 정렬 / 필터)를 손실 없이 이어받아야 한다 (공개 이슈 #75).
이 모듈에서 특히 오래 남아 있던 자리는 **폼 취소 leg** 이다. 저장 leg 는 목적지가 고정 경로라
일찍 교정됐지만, 취소 버튼은 목적지가 삼항 표현식(`route?.id ? '/admin/pages/' + id : '/admin/pages'`)
이라 정적 검사에서 판정 자체가 포기돼 "저장하면 유지 / 취소하면 리셋" 비대칭이 남았다.
audit 룰이 표현식 안의 문자열 리터럴로 클러스터를 판정하도록 승격되면서 검출 대상이 됐다.
axes:
# 목록 클러스터 안에서 발생하는 이동 leg
leg: [back, cancel] # 상세→목록 / 폼 취소→직전 화면
effects:
- list_return_preserves_query # 복귀 이동이 URL 목록 상태를 그대로 복원
test_files:
- modules/_bundled/sirsoft-page/tests/Playwright/specs/admin/page-admin-list-context-round-trip.spec.ts
- modules/_bundled/sirsoft-page/resources/js/__tests__/layouts/admin-list-context-round-trip.test.ts
- .claude/scripts/audit/__tests__/layout-list-context-navigate-merge-query.test.cjs
validation:
note: |
진입 leg(목록 행 → 상세)는 행 끝 액션 메뉴가 포털로 렌더돼 브라우저 로케이터가 불안정하므로
레이아웃 테스트가 실 JSON 의 액션 params 로 고정한다. 브라우저 spec 은 진입이 만들어 내는 URL 상태를
그대로 재현한 뒤 복귀 leg 를 실측한다. 재발 차단은 audit 룰
`layout-list-context-navigate-merge-query` (error) 가 담당한다.
@@ -132,6 +132,7 @@
}
},
{
"comment": "audit:allow layout-list-context-navigate-merge-query 필터 초기화 — 검색/필터 조건과 페이지를 모두 비우는 의도적 리셋이므로 병합하지 않는다",
"handler": "navigate",
"params": {
"path": "/admin/plugins/sirsoft-gdpr/consent-log",
@@ -8,6 +8,8 @@
### Fixed
- 관리자 주문 목록에서 주문번호를 눌러 상세로 들어갈 때 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 주문 목록에서 '이 주문자의 주문 검색'을 실행하면 검색창은 비어 보이는데 직전 검색어가 계속 적용돼 결과가 어긋나던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 해외결제(CBT) 연결 진단을 실행했을 때 결과 안내가 실제 문장 대신 내부 식별자로 표시되던 문제를 수정했습니다.
## [1.0.1] - 2026-07-16
@@ -52,7 +52,7 @@
"props": { "className": "font-medium text-blue-600 dark:text-blue-400 cursor-pointer hover:underline" },
"text": "{{row.order_number}}",
"actions": [
{ "type": "click", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders/{{row.order_number}}" } }
{ "type": "click", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders/{{row.order_number}}", "mergeQuery": true, "query": {} } }
]
}
]
@@ -178,14 +178,14 @@
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "_local", "filter.ordererUuid": "{{row.user?.uuid}}", "filter.searchField": "all", "filter.searchKeyword": "", "ordererSearchResults": "{{[{ uuid: row.user?.uuid, name: row.user?.name || row.address?.orderer_name, email: row.user?.email || '' }]}}" } },
{ "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "query": { "orderer_uuid": "{{row.user?.uuid}}", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } }
{ "comment": "이 주문자의 주문만 보기 — 앞선 setState 가 검색어를 비우므로 URL 의 검색 조건도 함께 비운다. 비우지 않으면 화면 입력창은 빈 채로 이전 검색어가 계속 적용된다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "orderer_uuid": "{{row.user?.uuid}}", "search_field": "", "search_keyword": "", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } }
]
},
"search_by_orderer_guest": {
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "_local", "filter.searchField": "orderer_name", "filter.searchKeyword": "{{row.address?.orderer_name || row.user?.name}}" } },
{ "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "query": { "search_field": "orderer_name", "search_keyword": "{{row.address?.orderer_name || row.user?.name}}", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } }
{ "handler": "setState", "params": { "target": "_local", "filter.searchField": "orderer_name", "filter.searchKeyword": "{{row.address?.orderer_name || row.user?.name}}", "filter.ordererUuid": "" } },
{ "comment": "비회원 주문자명으로 검색 — 회원 지정 필터(orderer_uuid)를 함께 비우지 않으면 이름 검색과 회원 필터가 겹쳐 결과가 비어 보인다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "search_field": "orderer_name", "search_keyword": "{{row.address?.orderer_name || row.user?.name}}", "orderer_uuid": "", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } }
]
}
}
@@ -8,6 +8,8 @@
### Fixed
- 관리자 주문 목록에서 주문번호를 눌러 상세로 들어갈 때 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 주문 목록에서 '이 주문자의 주문 검색'을 실행하면 검색창은 비어 보이는데 직전 검색어가 계속 적용돼 결과가 어긋나던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 오류 안내가 뜨기는 하지만 내용이 비어 있던 문제를 수정했습니다. 설정 저장에 실패하면 서버가 알려 준 사유가 그대로 표시됩니다.
- 설정 화면의 연동 점검 결과 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다.

Some files were not shown because too many files have changed in this diff Show More