Merge pull request from gnuboard:HeuJung/issue491
HeuJung/issue491
This commit is contained in:
commit
6b791679a6
213 files changed
+9074
-620
No files matched your search
@@ -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)
|
||||
|
||||
### 중첩 리소스 스코프 / 계층 무결성
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
### Added
|
||||
|
||||
#### 사이트맵
|
||||
|
||||
- 게시글·상품이 수백만 건으로 늘어나도 사이트맵을 안정적으로 생성하도록 개선했습니다. 예전에는 사이트맵을 만들 때 전체 데이터를 한꺼번에 메모리에 올리다가 데이터가 많은 사이트에서 생성이 실패했지만, 이제 나눠 읽어 처리하므로 메모리 부족 없이 완성됩니다. (#79 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
- 사이트맵을 검색엔진 규격(파일당 5만 URL / 50MB)에 맞춰 여러 파일로 자동 분할하고, 이를 묶는 색인 파일을 함께 만듭니다. 한 파일에 담을 URL 수는 SEO 설정에서 조정할 수 있습니다.
|
||||
- 게시글·상품·페이지를 공개/비공개로 바꾸거나 삭제하면 사이트맵에서 해당 항목만 자동으로 더하거나 빼도록 바꿨습니다. 전체를 다시 만들지 않고 바뀐 부분만 반영하므로 항상 최신 상태가 빠르게 유지됩니다.
|
||||
@@ -17,6 +19,8 @@
|
||||
- 사이트맵의 갱신 빈도(changefreq) 값을 검색엔진 표준 어휘(always/hourly/daily/weekly/monthly/yearly/never)로 맞춰, 규격에 없는 값은 사이트맵에 출력되지 않도록 했습니다.
|
||||
- 관리자가 사이트맵 전체 재생성을 실행하면, 완료되거나 실패했을 때 실행한 관리자에게 알림을 보냅니다. 재생성이 오래 걸려 화면을 떠나 있어도 결과를 놓치지 않습니다. (기본은 앱 내 알림이며 관리자 알림 설정에서 이메일도 켤 수 있습니다. 매일 자동 생성이나 글·상품 변경에 따른 재생성은 알림을 보내지 않습니다.)
|
||||
|
||||
#### 설치·서버 환경
|
||||
|
||||
- 설치할 때 `root` 같은 데이터베이스 최고 권한 계정을 입력하면 설치가 진행되지 않고, 왜 사용할 수 없는지와 어떻게 해야 하는지를 함께 안내합니다. 최고 권한 계정 정보가 유출되면 데이터베이스 전체가 위험해지기 때문이며, 사이트 전용 데이터베이스 계정을 새로 만들어 필요한 권한만 부여해 입력하시면 됩니다. 읽기 전용 데이터베이스를 따로 쓰는 경우에도 동일하게 적용됩니다.
|
||||
- 일부 서버 설정에서 모든 페이지가 백지로 나오던 문제에 대응했습니다. 서버에 `.js`·`.css`·`.json` 주소를 가로채는 정적 파일 최적화 설정(aaPanel·CyberPanel·Plesk 기본 템플릿 등)이 있으면 G7 의 일부 주소가 PHP 에 전달되지 못해 화면이 뜨지 않았습니다. 이제 G7 이 확장자 없는 주소도 함께 제공하며, 화면이 뜨지 않으면 브라우저가 이를 감지해 자동으로 전환하므로 서버 설정을 바꾸지 않아도 사이트가 표시됩니다. (sir.kr 커뮤니티의 hang 님께서 제보해주셨습니다.)
|
||||
- 자산 파일 주소 방식을 설치 마법사가 자동으로 감지해 반영하고, 설치 후에도 관리자 > 환경설정 > 일반에서 바꿀 수 있습니다. 설치 2단계 서버 요구사항 화면에서 감지 결과를 미리 확인할 수 있고, 3단계에서 직접 고를 수도 있습니다. 화면이 아예 뜨지 않는 상황을 위해 `php artisan g7:asset-url-mode` 명령으로도 확인·전환할 수 있습니다. 검색엔진 봇은 자바스크립트를 실행하지 않으므로, 이 설정을 확정해 두면 봇에게도 올바른 주소가 전달됩니다.
|
||||
@@ -28,19 +32,45 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
#### 검색엔진 봇 화면
|
||||
|
||||
- 검색엔진 봇에게만 게시글·답글의 원글·댓글·페이지·상품 설명의 본문이 통째로 빈 채 전달되던 문제를 수정했습니다. 위지윅 편집기 플러그인이 켜져 있으면 본문 영역이 플러그인 화면으로 교체되는데, 봇용 화면을 만드는 쪽이 그 교체 규칙을 몰라 내용 없는 껍데기만 만들었습니다. 미리보기용 요약(메타 태그)에는 본문이 들어가 있어 겉으로는 정상으로 보였습니다. 이제 일반 화면과 봇용 화면이 같은 본문을 출력합니다. 업그레이드 시 이미 만들어져 있던 봇용 화면은 한 번 비워져 새로 만들어집니다. (#86 @koojunho 님께서 제보해주셨습니다.)
|
||||
- 봇용 화면이 자료를 불러오는 주소에 검색어 같은 값을 끼워 넣어 둔 경우, 그 값이 해석되지 않아 목록이 통째로 비어 있던 문제를 수정했습니다. 예전에는 주소 안의 글 번호만 해석했고 검색어·화면 초기값은 그대로 남아 조회에 실패했습니다.
|
||||
- 봇용 화면이 일반 화면과 다르게 해석하던 문법들을 일반 화면과 동일하게 맞췄습니다. 여러 조건을 묶은 표시 조건, 반복 목록의 다른 표기 방식, 글자만 넣은 조각, 데스크톱 전용으로 지정한 하위 구성·반복 목록, 지연 표기한 번역, 화면 시작 시 정해지는 초기값이 해당됩니다. 조건이 거짓인데도 내용이 노출되거나, 화면에는 보이는 내용이 봇에게만 사라지던 차이가 없어집니다.
|
||||
- 봇용 화면에서 값 대신 `true`/`false` 가 출력되던 문제를 수정했습니다. 두 값 중 채워진 쪽을 쓰는 표기(`||`)가 값이 아니라 참·거짓으로 계산되어, 검색 화면 주소에 검색어 대신 `true` 가 들어가는 등의 증상이 있었습니다. 화면에 넣은 값이 참·거짓이거나 비어 있을 때도 봇용 화면에만 `false` 같은 글자가 그대로 노출되던 문제를 함께 수정했습니다.
|
||||
- 검색엔진 봇용 화면에 넣는 사용자 작성 본문을 일반 화면과 같은 기준으로 정리하도록 수정했습니다. 예전에는 봇용 화면에만 본문이 아무 검사 없이 들어가, 글에 넣어 둔 스크립트나 외부 프레임이 봇용 주소로 접속했을 때 실제로 실행될 수 있었습니다. 또 서식 없이 글자로만 저장한 글의 태그가 봇용 화면에서만 서식으로 해석되어 두 화면의 본문이 달라 보였습니다. 이제 서식 없는 글은 글자 그대로, 서식 있는 글은 위험한 요소를 걷어낸 뒤 표시되며, 일반 화면과 결과가 같습니다. 게시글·답글·페이지·상품 설명 모두에 적용됩니다.
|
||||
- 봇용 화면의 선택 상자(정렬·분류 등)에 항목 이름 대신 내부 식별자가 글자로 노출되던 문제를 수정했습니다. 이제 일반 화면과 같이 선택 항목의 이름이 표시됩니다.
|
||||
- 봇용 화면에만 일반 화면에 없는 내용이 나타나거나, 반대로 있어야 할 내용이 사라지던 경우를 정리했습니다. 값이 비어 있을 때 대신 다른 내용을 채워 넣던 동작, 화면 폭 조건이 여러 개 겹칠 때 적용 순서가 달라지던 동작, 알아볼 수 없는 형태의 표시 조건을 봇용 화면에서만 숨기던 동작이 해당됩니다.
|
||||
|
||||
#### 화면 표시·목록 상태
|
||||
|
||||
- 목록 표의 각 칸에 넣은 날짜·숫자 서식이 적용되지 않아 값이 비어 보이던 문제를 수정했습니다. 같은 서식을 일반 화면에서 쓰면 정상이었지만 목록 표의 칸 안에서는 값이 사라지거나(날짜 서식) 서식이 빠진 원래 값이 그대로 나왔습니다(숫자·대문자 서식). 목록 표와 카드 목록, 펼침 영역 등 반복해서 그려지는 모든 자리에서 서식이 정상 적용됩니다. (#87 @glitter-gim 님께서 제보해주셨습니다.)
|
||||
- 목록 화면에서 글이나 항목을 열어 보고 돌아올 때 보고 있던 페이지·검색어·필터가 사라지던 문제를 화면 엔진 차원에서 함께 수정했습니다. '현재 주소의 조건을 유지한다'고 지정해도 덧붙일 값이 없으면 유지가 아예 동작하지 않아 조건이 통째로 사라지던 동작을 바로잡았습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
|
||||
|
||||
#### 확장·데이터베이스 안정성
|
||||
|
||||
- 데이터베이스 연결이 일시적으로 끊기거나 확장 설치가 중간에 실패한 직후 확장 목록을 다시 만들면, 설치된 모듈·플러그인 정보가 통째로 비워지면서 게시판·쇼핑몰 등 모든 확장 화면이 500 오류를 내던 문제를 수정했습니다. 이제 확장 폴더가 그대로 남아 있는데 목록만 비어 있으면 기존 정보를 지우지 않고 보존하며, 왜 갱신하지 않았는지 로그에 남깁니다.
|
||||
- 확장 목록이 손상된 상태에서 복구 명령을 실행하면 목록은 되살아나지만 확장 기능 연결이 비어 있는 채로 남아, 명령을 두 번 실행해야 완전히 복구되던 문제를 수정했습니다. 이제 한 번 실행으로 모두 복구됩니다.
|
||||
- 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다.
|
||||
|
||||
#### 설정 저장·입력 검증
|
||||
|
||||
- SEO 설정의 캐시 항목(SEO 캐시 사용·유지 시간, Sitemap 캐시 유지 시간)이 저장해도 적용되지 않던 문제를 수정했습니다. 이제 고급 설정의 캐시 값이 기준이 되고, SEO 설정에서 값을 지정하면 그 값이 우선하며, 비워두면 고급 설정을 따릅니다. 업그레이드 시 기존에 기본값과 다르게 지정해 두셨던 값은 그대로 살아나 이제부터 실제로 적용되므로 SEO 캐시 동작이 달라질 수 있습니다. 기본값 그대로였던 항목은 "지정 안 함"으로 정리되어 동작이 바뀌지 않습니다.
|
||||
- 한글로 쓴 설명이 글자 수 제한에 걸리지 않는데도 "너무 깁니다" 로 거부되던 문제를 수정했습니다. 글자 수를 바이트 단위로 세는 바람에 한글 한 글자가 세 글자로 계산되어, 500자 제한에서 167자만 넘어도 저장이 막혔습니다. 역할·권한·모듈·플러그인·템플릿 설명에 모두 해당합니다.
|
||||
- 사용하던 언어팩을 끈 뒤 기존에 작성해 둔 내용을 수정하려 하면 저장이 통째로 막히던 문제를 수정했습니다. 꺼진 언어로 입력해 둔 번역이 남아 있으면 "지원하지 않는 언어" 로 거부되었습니다. 이제 그 번역은 그대로 보존한 채 수정할 수 있고, 언어팩을 다시 켜면 번역도 함께 되살아납니다.
|
||||
- 알림 템플릿에서 제목을 비워 둘 수 있는데도 저장할 때 제목을 요구하던 문제를 수정했습니다. 제목 개념이 없는 발송 수단에서도 템플릿을 저장할 수 있습니다.
|
||||
- 알림 정의에 존재하지 않는 발송 수단 이름이 그대로 저장되던 문제를 수정했습니다. 이제 사용할 수 없는 수단은 저장 시 안내와 함께 막히며, 플러그인이 추가한 수단은 정상적으로 선택할 수 있습니다. 수단을 제공하던 플러그인을 껐더라도 이미 저장된 설정은 계속 수정할 수 있습니다.
|
||||
- 등록한 일정이 하나라도 있으면 일정 관리 목록이 열리지 않고 "총 0개"로만 표시되던 문제를 수정했습니다. 실행 이력 화면도 동일한 원인으로 열리지 않았으며, 이제 두 화면 모두 정상적으로 목록을 보여줍니다.
|
||||
|
||||
#### 안내 문구·주소 처리
|
||||
|
||||
- 일부 안내가 실제 문장 대신 내부 식별자(`messages.auth.unauthenticated` 같은 문자열)로 표시되던 문제를 수정했습니다. 로그인하지 않았거나 역할 권한이 없어 요청이 거절될 때, 메뉴에 역할을 지정할 때, 확장 식별자를 잘못 입력했을 때, 활동 로그를 기간·유형으로 검색할 때가 해당됩니다.
|
||||
- 플러그인을 GitHub 주소나 ZIP 파일로 설치할 때의 입력 안내가 내부 식별자로 표시되던 문제를 수정했습니다. 주소 형식이 잘못됐거나, ZIP이 아닌 파일을 올렸거나, 파일 용량을 초과했을 때 정상 문장으로 안내합니다.
|
||||
- 레이아웃 편집기 화면이 정상 동작하는데도 서버가 "페이지 없음"(404)으로 응답하던 문제를 수정했습니다. 화면은 그대로 보였지만 브라우저 캐싱과 서버 접속 기록에서 없는 페이지로 취급되던 상태입니다.
|
||||
- 주소 끝에 슬래시가 붙은 형태(예: `/admin/`)로 접속하면 화면이 "페이지 없음"(404)으로 뜨던 문제를 수정했습니다. 특히 로그인이 풀린 상태에서 이렇게 접속하면 로그인 화면으로 넘어가지 않고 404가 나타났습니다. 이제 끝 슬래시가 있어도 정상적으로 같은 페이지로 연결됩니다.
|
||||
|
||||
#### 메뉴·플러그인 설정 무결성
|
||||
|
||||
- 메뉴를 수정할 때 자기 자신의 하위 메뉴 아래로 옮길 수 있던 문제를 수정했습니다. 이렇게 옮기면 메뉴 트리가 끊어져 관리자 화면에서 되돌릴 수 없었습니다. 메뉴 순서 변경 화면과 동일한 기준이 적용됩니다.
|
||||
- 플러그인 설정을 저장할 때, 그 플러그인이 제공하지 않는 항목까지 설정 파일에 함께 기록되던 문제를 수정했습니다. 이제 플러그인이 실제로 제공하는 설정 항목만 저장됩니다.
|
||||
|
||||
|
||||
+575
-60
@@ -4,6 +4,25 @@ namespace App\Seo;
|
||||
|
||||
class ComponentHtmlMapper
|
||||
{
|
||||
/**
|
||||
* SEO 렌더링 기준 뷰포트 폭 (px)
|
||||
*
|
||||
* 검색 봇은 데스크톱으로 간주하므로 responsive 오버라이드 매칭에 이 값을 사용합니다.
|
||||
*/
|
||||
private const DESKTOP_VIEWPORT_WIDTH = 1024;
|
||||
|
||||
/**
|
||||
* responsive 브레이크포인트 프리셋 → [min, max] 폭 범위 (px)
|
||||
*
|
||||
* React ResponsiveManager.ts 의 BREAKPOINT_PRESETS 와 동일한 값입니다.
|
||||
*/
|
||||
private const BREAKPOINT_PRESETS = [
|
||||
'mobile' => [0, 767],
|
||||
'tablet' => [768, 1023],
|
||||
'desktop' => [1024, PHP_INT_MAX],
|
||||
'portable' => [0, 1023],
|
||||
];
|
||||
|
||||
/**
|
||||
* 템플릿 제공 컴포넌트 매핑 (seo-config.json의 component_map)
|
||||
*/
|
||||
@@ -61,6 +80,21 @@ class ComponentHtmlMapper
|
||||
*/
|
||||
private array $fieldsContext = [];
|
||||
|
||||
/**
|
||||
* 사용자 작성 HTML 정화기 (raw 렌더 모드에서 지연 생성)
|
||||
*/
|
||||
private ?HtmlSanitizer $htmlSanitizer = null;
|
||||
|
||||
/**
|
||||
* `value` prop 을 텍스트로 승격하지 않는 태그 목록
|
||||
*
|
||||
* 폼 컨트롤의 `value` 는 현재 선택값(속성)이지 화면에 보이는 글자가 아닙니다.
|
||||
* React 는 `<select>` 안에 `<option>` 라벨을 그리므로, 봇 화면에서만 `value` 가
|
||||
* 글자로 노출되면(예: `<select>created_at_desc</select>`) 두 화면이 어긋나고
|
||||
* 색인에도 내부 식별자가 섞입니다.
|
||||
*/
|
||||
private const VALUE_NOT_TEXT_TAGS = ['select', 'option', 'input', 'textarea', 'progress', 'meter'];
|
||||
|
||||
/**
|
||||
* 템플릿 제공 컴포넌트 매핑을 설정합니다.
|
||||
*
|
||||
@@ -157,6 +191,15 @@ class ComponentHtmlMapper
|
||||
$html = '';
|
||||
|
||||
foreach ($components as $component) {
|
||||
// children 배열 내 문자열 = 텍스트 노드
|
||||
// React DynamicRenderer 는 이 문자열을 그대로 반환한다(표현식·번역 미해석).
|
||||
// SEO 가 여기서 표현식을 해석하면 반대 방향의 패리티 격차가 생기므로,
|
||||
// 이스케이프만 하고 리터럴로 출력한다.
|
||||
if (is_string($component)) {
|
||||
$html .= e($component);
|
||||
|
||||
continue;
|
||||
}
|
||||
if (! is_array($component)) {
|
||||
continue;
|
||||
}
|
||||
@@ -176,27 +219,24 @@ class ComponentHtmlMapper
|
||||
*/
|
||||
private function renderComponent(array $component, array $context, ExpressionEvaluator $evaluator): string
|
||||
{
|
||||
// SEO는 데스크톱 뷰로 렌더링 (검색 봇 = 데스크톱)
|
||||
// responsive.desktop 오버라이드를 기본 속성에 병합
|
||||
if (isset($component['responsive']['desktop'])) {
|
||||
$desktop = $component['responsive']['desktop'];
|
||||
if (isset($desktop['props'])) {
|
||||
$component['props'] = array_merge($component['props'] ?? [], $desktop['props']);
|
||||
}
|
||||
if (isset($desktop['if'])) {
|
||||
$component['if'] = $desktop['if'];
|
||||
}
|
||||
if (isset($desktop['text'])) {
|
||||
$component['text'] = $desktop['text'];
|
||||
}
|
||||
}
|
||||
// Extension Point 주입 컴포넌트의 props를 평가 컨텍스트에 주입
|
||||
// LayoutExtensionService가 호스트 props를 주입 컴포넌트 최상위 extensionPointProps로
|
||||
// 부착하므로, 여기서 해석해 컨텍스트에 넣어야 {{extensionPointProps.xxx}}가 평가된다.
|
||||
// $context는 값 전달이므로 이 노드의 서브트리에만 상속되고 형제로 새지 않는다
|
||||
// (React DynamicRenderer의 extendedDataContext 전파와 동형).
|
||||
// if/iteration 평가에도 이미 반영되어야 하므로 가장 먼저 처리한다.
|
||||
$this->injectExtensionPointContext($component, $context, $evaluator);
|
||||
|
||||
// 조건부 렌더링 (if)
|
||||
if (isset($component['if'])) {
|
||||
$condition = $evaluator->evaluate($component['if'], $context);
|
||||
if ($condition === '' || $condition === 'false' || $condition === '0') {
|
||||
return '';
|
||||
}
|
||||
// type: "iterator" → iteration 속성으로 변환 (React DynamicRenderer와 동일 매핑)
|
||||
$component = $this->normalizeIteratorNode($component);
|
||||
|
||||
// SEO는 데스크톱 뷰로 렌더링 (검색 봇 = 데스크톱)
|
||||
// 데스크톱 폭에 매칭되는 responsive 오버라이드를 기본 속성에 병합
|
||||
$component = $this->applyResponsiveOverrides($component);
|
||||
|
||||
// 조건부 렌더링 (if / condition / conditions)
|
||||
if (! $this->shouldRender($component, $context, $evaluator)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 반복 렌더링 (iteration)
|
||||
@@ -260,9 +300,375 @@ class ComponentHtmlMapper
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension Point 주입 컴포넌트의 props를 평가 컨텍스트에 주입합니다.
|
||||
*
|
||||
* LayoutExtensionService는 extension_point 호스트의 props를 주입 컴포넌트 최상위
|
||||
* `extensionPointProps` 키로 부착합니다. 주입 컴포넌트는 `{{extensionPointProps.content}}`
|
||||
* 형태로 그 값을 참조하므로, 렌더링 전에 표현식을 해석해 컨텍스트에 넣어야 합니다.
|
||||
*
|
||||
* 해석된 값은 이 노드와 그 자손 전체에 상속됩니다 (React DynamicRenderer가
|
||||
* extendedDataContext를 자식에게 전달하는 동작과 동일).
|
||||
*
|
||||
* `extensionPointCallbacks`는 의도적으로 주입하지 않습니다 — SEO 파이프라인에는
|
||||
* 액션 디스패처가 없고, 액션 배열을 컨텍스트에 넣으면 HTML에 JSON 파편이
|
||||
* 노출될 수 있습니다.
|
||||
*
|
||||
* @param array $component 컴포넌트 정의 (해석 후 extensionPointProps 키 제거)
|
||||
* @param array $context 데이터 컨텍스트 (해석된 값이 주입됨)
|
||||
* @param ExpressionEvaluator $evaluator 표현식 평가기
|
||||
*/
|
||||
private function injectExtensionPointContext(array &$component, array &$context, ExpressionEvaluator $evaluator): void
|
||||
{
|
||||
if (! isset($component['extensionPointProps']) || ! is_array($component['extensionPointProps'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$context['extensionPointProps'] = $this->resolveAllProps(
|
||||
$component['extensionPointProps'],
|
||||
$context,
|
||||
$evaluator
|
||||
);
|
||||
|
||||
// iteration 재진입 시 항목별로 재해석되지 않도록 제거
|
||||
// (React는 바깥 컨텍스트 기준 1회 해석 후 상속)
|
||||
unset($component['extensionPointProps']);
|
||||
}
|
||||
|
||||
/**
|
||||
* `type: "iterator"` 노드를 `iteration` 속성 형태로 정규화합니다.
|
||||
*
|
||||
* React DynamicRenderer가 `{ type: "iterator", data, itemName, indexName }` 를
|
||||
* `iteration: { source, item_var, index_var }` 로 변환하는 것과 동일합니다.
|
||||
*
|
||||
* @param array $component 컴포넌트 정의
|
||||
* @return array 정규화된 컴포넌트 정의
|
||||
*/
|
||||
private function normalizeIteratorNode(array $component): array
|
||||
{
|
||||
if (($component['type'] ?? null) !== 'iterator' || ! isset($component['data'])) {
|
||||
return $component;
|
||||
}
|
||||
|
||||
$component['iteration'] = [
|
||||
'source' => $component['data'],
|
||||
'item_var' => $component['itemName'] ?? 'item',
|
||||
'index_var' => $component['indexName'] ?? null,
|
||||
];
|
||||
|
||||
unset($component['data'], $component['itemName'], $component['indexName']);
|
||||
|
||||
return $component;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데스크톱 폭에 해당하는 responsive 오버라이드를 병합합니다.
|
||||
*
|
||||
* SEO는 검색 봇을 데스크톱 뷰포트로 간주하므로 `desktop` 프리셋과 데스크톱 폭
|
||||
* (1024px 이상)을 포함하는 숫자 범위 키만 적용합니다. mobile/tablet/portable 은
|
||||
* 데스크톱에서 매칭되지 않으므로 무시합니다.
|
||||
*
|
||||
* 오버라이드 항목은 React와 동일하게 props/if/text/children/iteration 입니다.
|
||||
*
|
||||
* @param array $component 컴포넌트 정의
|
||||
* @return array 오버라이드가 병합된 컴포넌트 정의
|
||||
*/
|
||||
private function applyResponsiveOverrides(array $component): array
|
||||
{
|
||||
if (empty($component['responsive']) || ! is_array($component['responsive'])) {
|
||||
return $component;
|
||||
}
|
||||
|
||||
$breakpoint = $this->matchingBreakpointKey($component['responsive']);
|
||||
if ($breakpoint === null) {
|
||||
return $component;
|
||||
}
|
||||
|
||||
$override = $component['responsive'][$breakpoint];
|
||||
|
||||
if (isset($override['props'])) {
|
||||
$component['props'] = array_merge($component['props'] ?? [], $override['props']);
|
||||
}
|
||||
foreach (['if', 'text', 'children', 'iteration'] as $key) {
|
||||
if (isset($override[$key])) {
|
||||
$component[$key] = $override[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $component;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데스크톱 폭에 매칭되는 responsive 키를 하나만 고릅니다.
|
||||
*
|
||||
* React `ResponsiveManager::getMatchingKey()` 와 동일한 우선순위를 따릅니다:
|
||||
* ① 커스텀 범위가 프리셋보다 우선 ② 좁은 범위가 넓은 범위보다 우선.
|
||||
* 매칭되는 키를 전부 순차 병합하면 JSON 키 선언 순서에 따라 결과가 달라진다.
|
||||
*
|
||||
* @param array $responsive responsive 오버라이드 맵
|
||||
* @return string|null 적용할 키 (없으면 null)
|
||||
*/
|
||||
private function matchingBreakpointKey(array $responsive): ?string
|
||||
{
|
||||
$matched = [];
|
||||
|
||||
foreach ($responsive as $breakpoint => $override) {
|
||||
if (! is_array($override)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$range = $this->parseBreakpointRange((string) $breakpoint);
|
||||
if ($range === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$min, $max] = $range;
|
||||
if ($min > self::DESKTOP_VIEWPORT_WIDTH || $max < self::DESKTOP_VIEWPORT_WIDTH) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matched[] = [
|
||||
'key' => (string) $breakpoint,
|
||||
'isPreset' => isset(self::BREAKPOINT_PRESETS[$breakpoint]),
|
||||
'width' => $max - $min,
|
||||
];
|
||||
}
|
||||
|
||||
if ($matched === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($matched, function (array $a, array $b): int {
|
||||
if ($a['isPreset'] !== $b['isPreset']) {
|
||||
return $a['isPreset'] ? 1 : -1;
|
||||
}
|
||||
|
||||
return $a['width'] <=> $b['width'];
|
||||
});
|
||||
|
||||
return $matched[0]['key'];
|
||||
}
|
||||
|
||||
/**
|
||||
* responsive 브레이크포인트 키를 [min, max] 범위로 파싱합니다.
|
||||
*
|
||||
* 프리셋(`desktop`/`portable`/`mobile`/`tablet`)과 `1024-`, `1280-1535`,
|
||||
* `-599` 같은 숫자 범위를 지원합니다. `min > max` 인 잘못된 범위는 무시합니다
|
||||
* (React `ResponsiveManager::parseRange()` 와 동일).
|
||||
*
|
||||
* @param string $breakpoint 브레이크포인트 키
|
||||
* @return array{0: int, 1: int}|null [min, max] (파싱 불가 시 null)
|
||||
*/
|
||||
private function parseBreakpointRange(string $breakpoint): ?array
|
||||
{
|
||||
if (isset(self::BREAKPOINT_PRESETS[$breakpoint])) {
|
||||
return self::BREAKPOINT_PRESETS[$breakpoint];
|
||||
}
|
||||
|
||||
if (! preg_match('/^(-?\d*)-(-?\d*)$/', $breakpoint, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$min = $matches[1] === '' ? 0 : (int) $matches[1];
|
||||
$max = $matches[2] === '' ? PHP_INT_MAX : (int) $matches[2];
|
||||
|
||||
return $min <= $max ? [$min, $max] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트를 렌더링해야 하는지 판정합니다 (if / condition / conditions).
|
||||
*
|
||||
* React RenderHelpers.evaluateRenderCondition과 동일한 우선순위를 따릅니다:
|
||||
* `if` → `condition`(별칭) → `conditions`(AND/OR 그룹 또는 if/else 체인).
|
||||
*
|
||||
* @param array $component 컴포넌트 정의
|
||||
* @param array $context 데이터 컨텍스트
|
||||
* @param ExpressionEvaluator $evaluator 표현식 평가기
|
||||
* @return bool 렌더링 대상이면 true
|
||||
*/
|
||||
private function shouldRender(array $component, array $context, ExpressionEvaluator $evaluator): bool
|
||||
{
|
||||
if (isset($component['if'])) {
|
||||
return $this->evaluateBooleanExpression($component['if'], $context, $evaluator);
|
||||
}
|
||||
|
||||
if (isset($component['condition'])) {
|
||||
return $this->evaluateBooleanExpression($component['condition'], $context, $evaluator);
|
||||
}
|
||||
|
||||
if (! isset($component['conditions'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->evaluateConditions($component['conditions'], $context, $evaluator);
|
||||
}
|
||||
|
||||
/**
|
||||
* conditions 속성(AND/OR 그룹 또는 if/else 체인)을 평가합니다.
|
||||
*
|
||||
* - 문자열 / `{and: [...]}` / `{or: [...]}` → 조건식 평가
|
||||
* - `[{if: ...}, {if: ...}, {}]` → 하나라도 매칭되면 true (if 없는 항목은 else 브랜치)
|
||||
* - 위 어느 형식도 아니면 **렌더링**한다 — React `evaluateRenderCondition()` 이 알 수 없는
|
||||
* 형식에 대해 경고 후 true 를 반환하므로, 여기서 false 를 주면 봇 화면에서만 노드가 사라진다.
|
||||
*
|
||||
* @param mixed $conditions conditions 속성 값
|
||||
* @param array $context 데이터 컨텍스트
|
||||
* @param ExpressionEvaluator $evaluator 표현식 평가기
|
||||
* @return bool 평가 결과
|
||||
*/
|
||||
private function evaluateConditions(mixed $conditions, array $context, ExpressionEvaluator $evaluator): bool
|
||||
{
|
||||
if ($this->isConditionExpression($conditions)) {
|
||||
return $this->evaluateConditionExpression($conditions, $context, $evaluator);
|
||||
}
|
||||
|
||||
if ($this->isConditionBranches($conditions)) {
|
||||
foreach ($conditions as $branch) {
|
||||
if (! is_array($branch)) {
|
||||
continue;
|
||||
}
|
||||
// if 없는 항목 = else 브랜치 (항상 매칭)
|
||||
if (! isset($branch['if'])) {
|
||||
return true;
|
||||
}
|
||||
if ($this->evaluateConditionExpression($branch['if'], $context, $evaluator)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 알 수 없는 형식 → 렌더링 (React 동형)
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 값이 단일 ConditionExpression(문자열 / `{and:[]}` / `{or:[]}`) 인지 판정합니다.
|
||||
*
|
||||
* React `ConditionEvaluator.isConditionExpression()` 과 동일한 판정입니다.
|
||||
*
|
||||
* @param mixed $value 판정 대상
|
||||
* @return bool ConditionExpression 이면 true
|
||||
*/
|
||||
private function isConditionExpression(mixed $value): bool
|
||||
{
|
||||
if (is_string($value) || is_bool($value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_array($value) && ! array_is_list($value)) {
|
||||
return array_key_exists('and', $value) || array_key_exists('or', $value);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 값이 ConditionBranch[] 인지 판정합니다.
|
||||
*
|
||||
* 빈 배열은 브랜치 배열로 보지 않으며, 첫 항목에 `if` 또는 `then` 이 있어야 합니다
|
||||
* (React `ConditionEvaluator.isConditionBranches()` 와 동일).
|
||||
*
|
||||
* @param mixed $value 판정 대상
|
||||
* @return bool ConditionBranch[] 이면 true
|
||||
*/
|
||||
private function isConditionBranches(mixed $value): bool
|
||||
{
|
||||
if (! is_array($value) || ! array_is_list($value) || $value === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$first = $value[0];
|
||||
if (! is_array($first)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array_key_exists('if', $first) || array_key_exists('then', $first);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건식(문자열 또는 AND/OR 그룹)을 평가합니다.
|
||||
*
|
||||
* 빈 AND 그룹은 true, 빈 OR 그룹은 false 를 반환합니다 (React와 동일).
|
||||
*
|
||||
* @param mixed $condition 조건식
|
||||
* @param array $context 데이터 컨텍스트
|
||||
* @param ExpressionEvaluator $evaluator 표현식 평가기
|
||||
* @return bool 평가 결과
|
||||
*/
|
||||
private function evaluateConditionExpression(mixed $condition, array $context, ExpressionEvaluator $evaluator): bool
|
||||
{
|
||||
if (! is_array($condition)) {
|
||||
return $this->evaluateBooleanExpression($condition, $context, $evaluator);
|
||||
}
|
||||
|
||||
if (array_key_exists('and', $condition)) {
|
||||
if (! is_array($condition['and']) || $condition['and'] === []) {
|
||||
return true;
|
||||
}
|
||||
foreach ($condition['and'] as $sub) {
|
||||
if (! $this->evaluateConditionExpression($sub, $context, $evaluator)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (array_key_exists('or', $condition)) {
|
||||
if (! is_array($condition['or']) || $condition['or'] === []) {
|
||||
return false;
|
||||
}
|
||||
foreach ($condition['or'] as $sub) {
|
||||
if ($this->evaluateConditionExpression($sub, $context, $evaluator)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 알 수 없는 조건 형식
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 조건 표현식을 boolean으로 평가합니다.
|
||||
*
|
||||
* 문자열 결과의 falsy 판정 목록은 React RenderHelpers.evaluateIfCondition과
|
||||
* 동일합니다 (`''`, `false`, `0`, `null`, `undefined` — 대소문자·공백 무시).
|
||||
*
|
||||
* @param mixed $condition 조건 표현식 (문자열 또는 boolean)
|
||||
* @param array $context 데이터 컨텍스트
|
||||
* @param ExpressionEvaluator $evaluator 표현식 평가기
|
||||
* @return bool 평가 결과
|
||||
*/
|
||||
private function evaluateBooleanExpression(mixed $condition, array $context, ExpressionEvaluator $evaluator): bool
|
||||
{
|
||||
if (is_bool($condition)) {
|
||||
return $condition;
|
||||
}
|
||||
|
||||
// 조건이 비어 있으면 조건 없음으로 간주 (React: `if (!ifCondition) return true`)
|
||||
if ($condition === null || $condition === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$resolved = $evaluator->evaluate((string) $condition, $context);
|
||||
|
||||
return ! in_array(strtolower(trim($resolved)), ['', 'false', '0', 'null', 'undefined'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 선언적 렌더 모드로 컴포넌트를 HTML로 변환합니다.
|
||||
*
|
||||
* 우선순위가 renderTag() 와 반대인 것은 의도된 것이다 — renderTag() 는 text 를
|
||||
* children 보다 먼저 쓰지만(React DynamicRenderer 동형), 이 경로는 자체 렌더링을
|
||||
* 가진 집합 컴포넌트(Select·Pagination·HtmlContent 등) 전용이라 모드 출력이 먼저다.
|
||||
* React 에서도 Select 는 options prop 으로 스스로 option 을 그리고 text/children 을
|
||||
* 무시하므로, 여기서 text 를 우선하면 옵션 목록이 통째로 사라져 오히려 어긋난다.
|
||||
*
|
||||
* @param string $mode 렌더 모드명 (render_modes 키)
|
||||
* @param string $tag 외부 래퍼 HTML 태그
|
||||
* @param array $configEntry seo-config.json의 component_map 엔트리
|
||||
@@ -298,7 +704,7 @@ class ComponentHtmlMapper
|
||||
|
||||
// 텍스트 콘텐츠 fallback
|
||||
if ($innerHtml === '') {
|
||||
$innerHtml = $this->resolveTextContent($component, $props, $context, $evaluator);
|
||||
$innerHtml = $this->resolveTextContent($component, $props, $context, $evaluator, $tag);
|
||||
}
|
||||
|
||||
return "<{$tag}{$attrs}>{$innerHtml}</{$tag}>";
|
||||
@@ -322,17 +728,26 @@ class ComponentHtmlMapper
|
||||
return "<{$tag}{$attrs}>";
|
||||
}
|
||||
|
||||
$innerHtml = $this->renderChildren($component, $context, $evaluator);
|
||||
// 노드 레벨 text가 있으면 children보다 우선 (React DynamicRenderer와 동일 우선순위).
|
||||
// text 키가 있으면 해석 결과가 빈 문자열이어도 그것이 최종 값이다 — React 는
|
||||
// text !== undefined 인 순간 children 경로로 내려가지 않는다(DynamicRenderer.tsx:2591,2639).
|
||||
$nodeText = $this->resolveNodeText($component, $context, $evaluator);
|
||||
|
||||
if ($innerHtml === '') {
|
||||
$innerHtml = $this->resolveTextContent($component, $props, $context, $evaluator);
|
||||
if ($nodeText !== null) {
|
||||
$innerHtml = $nodeText;
|
||||
} else {
|
||||
$innerHtml = $this->renderChildren($component, $context, $evaluator);
|
||||
|
||||
if ($innerHtml === '') {
|
||||
$innerHtml = $this->resolveTextContent($component, $props, $context, $evaluator, $tag);
|
||||
}
|
||||
}
|
||||
|
||||
// dangerouslySetInnerHTML 처리
|
||||
// dangerouslySetInnerHTML 처리 — 일반 화면과 동일 강도로 정화 후 삽입
|
||||
if ($innerHtml === '' && isset($props['dangerouslySetInnerHTML'])) {
|
||||
$rawHtml = $evaluator->evaluate((string) $props['dangerouslySetInnerHTML'], $context);
|
||||
if ($rawHtml !== '') {
|
||||
$innerHtml = $rawHtml;
|
||||
$innerHtml = $this->htmlSanitizer()->sanitize($rawHtml);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,39 +802,37 @@ class ComponentHtmlMapper
|
||||
|
||||
$html = '';
|
||||
foreach ($data as $item) {
|
||||
if ($itemAttrs) {
|
||||
// 속성 기반 렌더링 (이미지 갤러리 등)
|
||||
$attrStr = '';
|
||||
foreach ($itemAttrs as $attrName => $fieldPattern) {
|
||||
$value = $this->resolveFieldPattern($fieldPattern, $item);
|
||||
if ($value !== '') {
|
||||
// src 속성의 상대 경로를 절대 경로로 변환
|
||||
if ($attrName === 'src' && ! str_starts_with($value, 'http')) {
|
||||
$value = url($value);
|
||||
}
|
||||
$attrStr .= " {$attrName}=\"".e($value).'"';
|
||||
$attrStr = $itemAttrs ? $this->buildIterateItemAttributes($itemAttrs, $item) : '';
|
||||
|
||||
if ($itemContent !== null) {
|
||||
// 콘텐츠 기반 렌더링 (탭 리스트, 선택 목록 등)
|
||||
// item_attrs 가 함께 선언되면 속성과 라벨을 모두 그린다 — `<option value="x">라벨</option>`
|
||||
// 처럼 둘이 다 필요한 요소를 표현하기 위함이다.
|
||||
$content = $this->resolveFieldPattern($itemContent, $item);
|
||||
$evaluatedContent = $content === ''
|
||||
? ''
|
||||
: e($evaluator->evaluate((string) $content, $context));
|
||||
|
||||
if ($evaluatedContent === '' && $attrStr === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$badge = '';
|
||||
if ($badgeField && isset($item[$badgeField])) {
|
||||
$evaluatedBadge = $evaluator->evaluate((string) $item[$badgeField], $context);
|
||||
if ($evaluatedBadge !== '' && $evaluatedBadge !== '0') {
|
||||
$badge = ' <span>('.e($evaluatedBadge).')</span>';
|
||||
}
|
||||
}
|
||||
|
||||
$html .= "<{$itemTag}{$attrStr}>".$evaluatedContent.$badge."</{$itemTag}>";
|
||||
} elseif ($itemAttrs) {
|
||||
// 속성 기반 렌더링 (이미지 갤러리 등)
|
||||
if (in_array($itemTag, $this->selfClosing)) {
|
||||
$html .= "<{$itemTag}{$attrStr}>";
|
||||
} else {
|
||||
$html .= "<{$itemTag}{$attrStr}></{$itemTag}>";
|
||||
}
|
||||
} elseif ($itemContent !== null) {
|
||||
// 콘텐츠 기반 렌더링 (탭 리스트 등)
|
||||
$content = $this->resolveFieldPattern($itemContent, $item);
|
||||
if ($content !== '') {
|
||||
$evaluatedContent = e($evaluator->evaluate((string) $content, $context));
|
||||
$badge = '';
|
||||
if ($badgeField && isset($item[$badgeField])) {
|
||||
$evaluatedBadge = $evaluator->evaluate((string) $item[$badgeField], $context);
|
||||
if ($evaluatedBadge !== '' && $evaluatedBadge !== '0') {
|
||||
$badge = ' <span>('.e($evaluatedBadge).')</span>';
|
||||
}
|
||||
}
|
||||
$html .= "<{$itemTag}>".$evaluatedContent.$badge."</{$itemTag}>";
|
||||
}
|
||||
} else {
|
||||
// 단순 아이템 (문자열)
|
||||
$value = is_string($item) ? e($item) : '';
|
||||
@@ -430,6 +843,34 @@ class ComponentHtmlMapper
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* iterate 모드 아이템의 속성 문자열을 만듭니다.
|
||||
*
|
||||
* @param array $itemAttrs render_modes 의 item_attrs 정의
|
||||
* @param mixed $item 현재 아이템
|
||||
* @return string 속성 문자열 (앞에 공백 포함, 없으면 빈 문자열)
|
||||
*/
|
||||
private function buildIterateItemAttributes(array $itemAttrs, mixed $item): string
|
||||
{
|
||||
$attrStr = '';
|
||||
|
||||
foreach ($itemAttrs as $attrName => $fieldPattern) {
|
||||
$value = $this->resolveFieldPattern($fieldPattern, $item);
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// src 속성의 상대 경로를 절대 경로로 변환
|
||||
if ($attrName === 'src' && ! str_starts_with($value, 'http')) {
|
||||
$value = url($value);
|
||||
}
|
||||
|
||||
$attrStr .= " {$attrName}=\"".e($value).'"';
|
||||
}
|
||||
|
||||
return $attrStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* format 타입: 포맷 문자열의 {key} 플레이스홀더를 props로 치환
|
||||
*
|
||||
@@ -492,7 +933,14 @@ class ComponentHtmlMapper
|
||||
}
|
||||
|
||||
/**
|
||||
* raw 타입: 원본 HTML/텍스트를 그대로 출력
|
||||
* raw 타입: 콘텐츠를 `isHtml` 판정에 따라 평문 또는 정화된 HTML 로 출력
|
||||
*
|
||||
* 일반 화면의 `HtmlContent` 컴포지트와 동일한 규칙입니다.
|
||||
* - `isHtml=false` → 평문 (전체 이스케이프)
|
||||
* - `isHtml=true`(기본) → 정화 후 HTML 로 출력
|
||||
*
|
||||
* 정화·이스케이프를 생략하면 봇 화면에서만 사용자 작성 `<script>` 가 살아남아,
|
||||
* 주소에 봇 파라미터를 붙이는 것만으로 실행됩니다.
|
||||
*
|
||||
* @param array $modeConfig render_modes 엔트리
|
||||
* @param array $configEntry component_map 엔트리
|
||||
@@ -515,7 +963,28 @@ class ComponentHtmlMapper
|
||||
return '';
|
||||
}
|
||||
|
||||
return $evaluator->evaluate((string) $contentExpr, $context);
|
||||
$content = $evaluator->evaluate((string) $contentExpr, $context);
|
||||
|
||||
if ($content === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// isHtml 미지정은 HTML 로 간주 (HtmlContent 의 기본값 isHtml = true 와 동일)
|
||||
if (! $this->evaluateBooleanExpression($props['isHtml'] ?? true, $context, $evaluator)) {
|
||||
return e($content);
|
||||
}
|
||||
|
||||
return $this->htmlSanitizer()->sanitize($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 정화기를 반환합니다 (지연 생성).
|
||||
*
|
||||
* @return HtmlSanitizer 정화기
|
||||
*/
|
||||
private function htmlSanitizer(): HtmlSanitizer
|
||||
{
|
||||
return $this->htmlSanitizer ??= new HtmlSanitizer;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1077,18 +1546,22 @@ class ComponentHtmlMapper
|
||||
* @param ExpressionEvaluator $evaluator 표현식 평가기
|
||||
* @return string 텍스트 콘텐츠
|
||||
*/
|
||||
private function resolveTextContent(array $component, array $props, array $context, ExpressionEvaluator $evaluator): string
|
||||
private function resolveTextContent(array $component, array $props, array $context, ExpressionEvaluator $evaluator, string $tag = ''): string
|
||||
{
|
||||
// 1. 컴포넌트 레벨 text 속성 (최우선)
|
||||
if (isset($component['text'])) {
|
||||
$text = $evaluator->evaluate((string) $component['text'], $context);
|
||||
if ($text !== '') {
|
||||
return e($text);
|
||||
}
|
||||
// 1. 컴포넌트 레벨 text 속성 (최우선) — 키가 있으면 빈 값이어도 그것이 최종 값
|
||||
$nodeText = $this->resolveNodeText($component, $context, $evaluator);
|
||||
if ($nodeText !== null) {
|
||||
return $nodeText;
|
||||
}
|
||||
|
||||
$skipValueProp = in_array(strtolower($tag), self::VALUE_NOT_TEXT_TAGS, true);
|
||||
|
||||
// 2. props 레벨 텍스트 추출 (seo-config.json의 text_props)
|
||||
foreach ($this->textProps as $textProp) {
|
||||
if ($textProp === 'value' && $skipValueProp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($props[$textProp])) {
|
||||
$text = $evaluator->evaluate((string) $props[$textProp], $context);
|
||||
if ($text !== '') {
|
||||
@@ -1100,6 +1573,40 @@ class ComponentHtmlMapper
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 레벨 text 속성을 해석합니다.
|
||||
*
|
||||
* @param array $component 컴포넌트 정의
|
||||
* @param array $context 데이터 컨텍스트
|
||||
* @param ExpressionEvaluator $evaluator 표현식 평가기
|
||||
* @return string 이스케이프된 텍스트 (없으면 빈 문자열)
|
||||
*/
|
||||
private function resolveNodeText(array $component, array $context, ExpressionEvaluator $evaluator): ?string
|
||||
{
|
||||
if (! array_key_exists('text', $component)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$expression = (string) $component['text'];
|
||||
|
||||
// JSX 시맨틱: bool / null 은 렌더되지 않는다.
|
||||
// React 는 단일 바인딩 text 를 raw 값으로 받아 그대로 자식에 넘기므로
|
||||
// {false} / {null} 이 화면에 나오지 않는다 (DynamicRenderer.tsx 의 renderChildren).
|
||||
// 문자열화하면 'false' 라는 글자가 봇 화면에만 노출된다.
|
||||
//
|
||||
// 판정에만 evaluateRaw 를 쓰고 출력은 evaluate 로 만든다 — evaluate 가 파이프·인라인
|
||||
// `$t:` 토큰 후처리를 담당하므로, 이를 건너뛰면 `?? '$t:key'` 같은 폴백 문자열의
|
||||
// 번역 토큰이 봇 화면에 그대로 남는다.
|
||||
$resolved = $evaluator->evaluateRaw($expression, $context);
|
||||
if (is_bool($resolved) || $resolved === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$text = $evaluator->evaluate($expression, $context);
|
||||
|
||||
return $text === '' ? '' : e($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 반복 렌더링을 처리합니다.
|
||||
*
|
||||
@@ -1134,6 +1641,14 @@ class ComponentHtmlMapper
|
||||
$templateComponent = $component;
|
||||
unset($templateComponent['iteration']);
|
||||
|
||||
// responsive 오버라이드가 항목 렌더링 시 iteration을 다시 주입해
|
||||
// 무한 재귀가 되지 않도록 함께 제거한다.
|
||||
if (isset($templateComponent['responsive']) && is_array($templateComponent['responsive'])) {
|
||||
foreach (array_keys($templateComponent['responsive']) as $breakpoint) {
|
||||
unset($templateComponent['responsive'][$breakpoint]['iteration']);
|
||||
}
|
||||
}
|
||||
|
||||
$html = '';
|
||||
foreach (array_values($data) as $index => $item) {
|
||||
$iterContext = array_merge($context, [
|
||||
|
||||
@@ -15,12 +15,18 @@ class DataSourceResolver
|
||||
* @param array $routeParams 라우트 파라미터 (예: ['id' => '123', 'slug' => 'clothes'])
|
||||
* @param string $locale 로케일
|
||||
* @param array $queryParams URL 쿼리 파라미터 (예: ['page' => '2', 'sort' => 'latest'])
|
||||
* @param array $extraContext 엔드포인트/params 표현식 평가에 사용할 추가 컨텍스트 (_global 등)
|
||||
* @return array data_source ID → 응답 데이터 매핑
|
||||
*/
|
||||
public function resolve(array $dataSources, array $seoDataSourceIds, array $routeParams, string $locale, array $queryParams = []): array
|
||||
public function resolve(array $dataSources, array $seoDataSourceIds, array $routeParams, string $locale, array $queryParams = [], array $extraContext = []): array
|
||||
{
|
||||
$context = [];
|
||||
|
||||
$evalContext = array_merge($extraContext, [
|
||||
'route' => array_merge($extraContext['route'] ?? [], $routeParams),
|
||||
'query' => $queryParams,
|
||||
]);
|
||||
|
||||
foreach ($dataSources as $ds) {
|
||||
$dsId = $ds['id'] ?? '';
|
||||
|
||||
@@ -34,11 +40,11 @@ class DataSourceResolver
|
||||
continue;
|
||||
}
|
||||
|
||||
// 라우트 파라미터 치환 (예: {{route.id}} → 123)
|
||||
$endpoint = $this->resolveEndpoint($endpoint, $routeParams);
|
||||
// 엔드포인트 표현식 치환 ({{route.id}}, {{query.q}}, {{_global.xxx}})
|
||||
$endpoint = $this->resolveEndpoint($endpoint, $evalContext);
|
||||
|
||||
// params 해석 ({{query.xxx}}, {{route.xxx}} 표현식 치환)
|
||||
$resolvedParams = $this->resolveParams($ds['params'] ?? [], $routeParams, $queryParams);
|
||||
$resolvedParams = $this->resolveParams($ds['params'] ?? [], $routeParams, $queryParams, $extraContext);
|
||||
|
||||
// API 호출
|
||||
$data = $this->callApi($endpoint, $ds, $locale, $resolvedParams);
|
||||
@@ -51,16 +57,21 @@ class DataSourceResolver
|
||||
/**
|
||||
* 엔드포인트의 표현식을 치환합니다.
|
||||
*
|
||||
* `{{route.xxx}}` 뿐 아니라 `{{query.xxx}}` / `{{_global.xxx}}` 등 컨텍스트 표현식을
|
||||
* 모두 해석합니다 — 검색 결과처럼 엔드포인트 문자열 안에서 쿼리·전역 상태를 보간하는
|
||||
* 데이터소스가 있기 때문입니다. 미해석 시 표현식이 URL에 그대로 남아 호출이 실패합니다.
|
||||
*
|
||||
* @param string $endpoint 원본 엔드포인트
|
||||
* @param array $routeParams 라우트 파라미터
|
||||
* @param array $evalContext 평가 컨텍스트 (route/query/_global 등)
|
||||
* @return string 치환된 엔드포인트
|
||||
*/
|
||||
private function resolveEndpoint(string $endpoint, array $routeParams): string
|
||||
private function resolveEndpoint(string $endpoint, array $evalContext): string
|
||||
{
|
||||
// {{route.xxx}} 및 {{route?.xxx}} 패턴 치환 (optional chaining 포함)
|
||||
return (string) preg_replace_callback('/\{\{route\??\.(\w+)\}\}/', function ($matches) use ($routeParams) {
|
||||
return $routeParams[$matches[1]] ?? '';
|
||||
}, $endpoint);
|
||||
if (! str_contains($endpoint, '{{')) {
|
||||
return $endpoint;
|
||||
}
|
||||
|
||||
return app(ExpressionEvaluator::class)->evaluate($endpoint, $evalContext);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,19 +83,20 @@ class DataSourceResolver
|
||||
* @param array $params 원본 params 정의
|
||||
* @param array $routeParams 라우트 파라미터
|
||||
* @param array $queryParams 쿼리 파라미터
|
||||
* @param array $extraContext 추가 평가 컨텍스트 (_global 등)
|
||||
* @return array 해석된 params (키 → 값)
|
||||
*/
|
||||
private function resolveParams(array $params, array $routeParams, array $queryParams): array
|
||||
private function resolveParams(array $params, array $routeParams, array $queryParams, array $extraContext = []): array
|
||||
{
|
||||
if (empty($params)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resolved = [];
|
||||
$evalContext = [
|
||||
$evalContext = array_merge($extraContext, [
|
||||
'route' => $routeParams,
|
||||
'query' => $queryParams,
|
||||
];
|
||||
]);
|
||||
|
||||
$evaluator = app(ExpressionEvaluator::class);
|
||||
|
||||
|
||||
+130
-18
@@ -83,8 +83,9 @@ class ExpressionEvaluator
|
||||
public function evaluateRaw(string $expression, array $context): mixed
|
||||
{
|
||||
// 단일 {{expr}} 패턴인 경우 원본 값 반환
|
||||
if (preg_match('/^\{\{(.+?)\}\}$/', trim($expression), $matches)) {
|
||||
$expr = trim($matches[1]);
|
||||
$trimmed = trim($expression);
|
||||
if ($this->isSingleBinding($trimmed)) {
|
||||
$expr = trim(substr($trimmed, 2, -2));
|
||||
|
||||
// 파이프 표현식: 원본 값을 파이프로 변환 후 반환
|
||||
if (PipeRegistry::hasPipes($expr)) {
|
||||
@@ -101,6 +102,14 @@ class ExpressionEvaluator
|
||||
return $value;
|
||||
}
|
||||
|
||||
// 비교·논리 연산은 참/거짓 값으로 돌려준다.
|
||||
// `resolveToValue()` 는 경로/리터럴 해석기라 `(a ?? 'text') === 'html'` 같은
|
||||
// 비교식을 null 로 되돌린다. 그 null 이 확장 주입 props 의 기본값으로 흘러들면
|
||||
// (예: `isHtml ?? true`) 레이아웃이 선언한 판정이 통째로 뒤집힌다.
|
||||
if ($this->isBooleanExpression($expr)) {
|
||||
return $this->coerceBooleanString($this->evaluate($trimmed, $context));
|
||||
}
|
||||
|
||||
// 연산자/함수 호출/리터럴이 포함된 표현식은 resolveToValue로 처리
|
||||
if (str_contains($expr, '??') || str_contains($expr, '?') || preg_match('/[\w$]+\s*\(/', $expr)
|
||||
|| str_starts_with($expr, '{') || str_contains($expr, '...')) {
|
||||
@@ -114,6 +123,105 @@ class ExpressionEvaluator
|
||||
return $this->evaluate($expression, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 표현식이 비교·논리 연산인지 판정합니다.
|
||||
*
|
||||
* 삼항(`? :`)과 화살표 함수(`=>`)는 결과가 참/거짓이 아니므로 제외합니다
|
||||
* (`??` 와 `?.` 은 삼항이 아니라 제외 대상에서 먼저 걷어냅니다).
|
||||
*
|
||||
* @param string $expr `{{}}` 를 벗겨낸 표현식
|
||||
* @return bool 비교·논리 연산이면 true
|
||||
*/
|
||||
private function isBooleanExpression(string $expr): bool
|
||||
{
|
||||
$normalized = str_replace(['??', '?.'], '', $expr);
|
||||
|
||||
if (str_contains($normalized, '=>') || str_contains($normalized, '?')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with(trim($expr), '!')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return preg_match('/(===|!==|==|!=|>=|<=|>|<|&&|\|\|)/', $normalized) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 참/거짓 문자열을 bool 로 되돌립니다.
|
||||
*
|
||||
* `||` 처럼 피연산자 값을 그대로 돌려주는 연산도 있으므로, 참/거짓 문자열이
|
||||
* 아니면 원본 문자열을 유지합니다.
|
||||
*
|
||||
* @param string $value 평가 결과 문자열
|
||||
* @return mixed bool 또는 원본 문자열
|
||||
*/
|
||||
private function coerceBooleanString(string $value): mixed
|
||||
{
|
||||
return match ($value) {
|
||||
'true' => true,
|
||||
'false' => false,
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 평가 결과 문자열의 truthy 여부를 판정합니다.
|
||||
*
|
||||
* 논리 연산자(`||`, `&&`)의 단락 평가 기준입니다.
|
||||
*
|
||||
* @param string $value 평가 결과 문자열
|
||||
* @return bool truthy 이면 true
|
||||
*/
|
||||
private function isTruthyValue(string $value): bool
|
||||
{
|
||||
return $value !== '' && $value !== 'false' && $value !== '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* 표현식이 단일 {{expr}} 바인딩 하나로만 구성되는지 판정합니다.
|
||||
*
|
||||
* `{{a}} - {{b}}` 처럼 바인딩이 둘 이상이면 false 를 반환해야 합니다.
|
||||
* 정규식 `/^\{\{(.+?)\}\}$/` 는 양끝 앵커 때문에 이런 다중 바인딩도 매치되어
|
||||
* 내부가 `a}} - {{b` 로 잘못 해석되므로, 중괄호 깊이를 직접 추적합니다.
|
||||
* 객체 리터럴(`{{ {outer: {inner: 'v'}} }}`)처럼 내부에 `}}` 가 등장하는
|
||||
* 경우까지 정확히 구분하기 위함입니다.
|
||||
*
|
||||
* @param string $trimmed 앞뒤 공백이 제거된 표현식
|
||||
* @return bool 단일 바인딩이면 true
|
||||
*/
|
||||
private function isSingleBinding(string $trimmed): bool
|
||||
{
|
||||
$length = strlen($trimmed);
|
||||
|
||||
if ($length < 5 || ! str_starts_with($trimmed, '{{') || ! str_ends_with($trimmed, '}}')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 여는 `{{` 를 깊이 2 로 두고 스캔 — 마지막 문자 이전에 깊이가 0 이 되면 바인딩이 둘 이상
|
||||
$depth = 2;
|
||||
for ($i = 2; $i < $length; $i++) {
|
||||
$char = $trimmed[$i];
|
||||
|
||||
if ($char === '{') {
|
||||
$depth++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char !== '}') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$depth--;
|
||||
if ($depth === 0) {
|
||||
return $i === $length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* ?? 연산자를 포함한 표현식을 원본 값으로 평가합니다.
|
||||
*
|
||||
@@ -156,6 +264,7 @@ class ExpressionEvaluator
|
||||
// 표현식으로 재귀 해석
|
||||
return $this->resolvePath($fallback, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO 메타 표현식을 컨텍스트와 함께 평가하여 최종 문자열을 반환합니다.
|
||||
*
|
||||
@@ -196,7 +305,7 @@ class ExpressionEvaluator
|
||||
|
||||
// 인라인 $t:key 토큰 해석 ({{}} 외부 텍스트 내 $t:key|param=value 패턴)
|
||||
if (str_contains($result, '$t:')) {
|
||||
$result = preg_replace_callback('/\$t:([\w.\-]+(?:\|[\w.\-]+=[\w.\-{}]+)*)/', function ($matches) use ($context) {
|
||||
$result = preg_replace_callback('/\$t:(?:defer:)?([\w.\-]+(?:\|[\w.\-]+=[\w.\-{}]+)*)/', function ($matches) use ($context) {
|
||||
return $this->resolveTranslation('$t:'.$matches[1], $context);
|
||||
}, $result);
|
||||
}
|
||||
@@ -213,7 +322,11 @@ class ExpressionEvaluator
|
||||
/**
|
||||
* $t: 번역 키를 해석합니다.
|
||||
*
|
||||
* @param string $expression 번역 표현식 ($t:key 또는 $t:key|param=value)
|
||||
* `$t:defer:key` 형태(프론트엔드에서 렌더 시점까지 해석을 미루는 표기)도
|
||||
* 동일한 키로 취급합니다 — SEO는 서버에서 한 번에 해석하므로 지연 개념이
|
||||
* 없고, `defer:` 를 키에 포함시키면 사전 조회가 실패해 빈 문자열이 됩니다.
|
||||
*
|
||||
* @param string $expression 번역 표현식 ($t:key, $t:defer:key 또는 $t:key|param=value)
|
||||
* @param array $context 데이터 컨텍스트 (파라미터 값의 {{}} 표현식 해석용)
|
||||
* @return string 번역된 문자열
|
||||
*/
|
||||
@@ -221,6 +334,10 @@ class ExpressionEvaluator
|
||||
{
|
||||
$key = substr($expression, 3);
|
||||
|
||||
if (str_starts_with($key, 'defer:')) {
|
||||
$key = substr($key, 6);
|
||||
}
|
||||
|
||||
// 파라미터 분리: $t:key|param1=value1|param2=value2
|
||||
$params = [];
|
||||
if (str_contains($key, '|')) {
|
||||
@@ -386,36 +503,31 @@ class ExpressionEvaluator
|
||||
}
|
||||
|
||||
// 2. 논리 OR: a || b
|
||||
// JS 시맨틱 — boolean이 아니라 "첫 truthy 피연산자"를 반환한다.
|
||||
// ({{query?.q || ''}} 같은 표현식이 값 대신 'true'가 되는 것을 방지)
|
||||
if (str_contains($expr, '||')) {
|
||||
$parts = $this->splitOutsideBrackets($expr, '||');
|
||||
if (count($parts) === 2) {
|
||||
$leftVal = $this->evaluateExpression(trim($parts[0]), $context);
|
||||
if ($leftVal !== '' && $leftVal !== 'false' && $leftVal !== '0') {
|
||||
return 'true';
|
||||
}
|
||||
$rightVal = $this->evaluateExpression(trim($parts[1]), $context);
|
||||
if ($rightVal !== '' && $rightVal !== 'false' && $rightVal !== '0') {
|
||||
return 'true';
|
||||
if ($this->isTruthyValue($leftVal)) {
|
||||
return $leftVal;
|
||||
}
|
||||
|
||||
return 'false';
|
||||
return $this->evaluateExpression(trim($parts[1]), $context);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 논리 AND: a && b
|
||||
// JS 시맨틱 — 좌변이 falsy면 좌변을, 아니면 우변을 반환한다.
|
||||
if (str_contains($expr, '&&')) {
|
||||
$parts = $this->splitOutsideBrackets($expr, '&&');
|
||||
if (count($parts) === 2) {
|
||||
$leftVal = $this->evaluateExpression(trim($parts[0]), $context);
|
||||
if ($leftVal === '' || $leftVal === 'false' || $leftVal === '0') {
|
||||
return 'false';
|
||||
}
|
||||
$rightVal = $this->evaluateExpression(trim($parts[1]), $context);
|
||||
if ($rightVal === '' || $rightVal === 'false' || $rightVal === '0') {
|
||||
return 'false';
|
||||
if (! $this->isTruthyValue($leftVal)) {
|
||||
return $leftVal;
|
||||
}
|
||||
|
||||
return 'true';
|
||||
return $this->evaluateExpression(trim($parts[1]), $context);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
namespace App\Seo;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 봇 화면에 삽입되는 사용자 작성 HTML 을 정화합니다.
|
||||
*
|
||||
* 일반 화면(React)은 `HtmlContent` 컴포지트가 DOMPurify 로 정화한 결과를 그리므로,
|
||||
* 봇 화면도 **같은 강도**로 정화해야 두 화면의 출력이 일치합니다. 정화하지 않으면
|
||||
* 봇 화면에서만 `<script>` / `onerror` 등이 살아 있어, 주소에 봇 파라미터를 붙이는
|
||||
* 것만으로 저장된 스크립트가 실행됩니다.
|
||||
*
|
||||
* 차단 목록은 아래 파일의 DOMPurify 설정을 옮긴 것입니다 — 한쪽만 고치면 두 화면의
|
||||
* 정화 강도가 어긋나므로 **함께 갱신**해야 합니다.
|
||||
*
|
||||
* @see templates/_bundled/sirsoft-basic/src/components/composite/HtmlContent.tsx
|
||||
* @see docs/backend/seo-system.md "봇 화면의 HTML 정화"
|
||||
*/
|
||||
class HtmlSanitizer
|
||||
{
|
||||
/**
|
||||
* 정화 대상 HTML 을 감쌀 래퍼 요소 id.
|
||||
*
|
||||
* DOM 파서가 요구하는 문서 골격을 씌운 뒤 이 요소의 자식만 되돌려 받습니다.
|
||||
*/
|
||||
private const ROOT_ID = 'g7-seo-sanitize-root';
|
||||
|
||||
/**
|
||||
* 요소와 내용을 통째로 제거할 태그 목록.
|
||||
*
|
||||
* 스크립트 실행 · 외부 콘텐츠 삽입 · 문서 구조 조작처럼 내부 텍스트를 남기는 것조차
|
||||
* 위험하거나 무의미한 태그입니다. (DOMPurify 의 `FORBID_CONTENTS` 계열 동작)
|
||||
*/
|
||||
private const DROP_WITH_CONTENT = [
|
||||
'script', 'noscript', 'style', 'template',
|
||||
'iframe', 'frame', 'frameset',
|
||||
'title', 'head',
|
||||
'svg', 'math',
|
||||
'plaintext', 'xmp', 'listing',
|
||||
'noframes', 'noembed',
|
||||
'audio', 'video',
|
||||
];
|
||||
|
||||
/**
|
||||
* 요소만 벗기고 내부 텍스트는 남길 태그 목록.
|
||||
*
|
||||
* 본문 텍스트를 품고 있을 수 있어 통째로 버리면 콘텐츠가 사라지는 태그입니다.
|
||||
* (DOMPurify 의 `KEEP_CONTENT: true` 기본 동작)
|
||||
*/
|
||||
private const UNWRAP_KEEP_CONTENT = [
|
||||
'object', 'embed', 'applet', 'portal',
|
||||
'link', 'meta', 'base',
|
||||
'form', 'input', 'textarea', 'select', 'option', 'button',
|
||||
'body', 'html',
|
||||
'source', 'track', 'canvas',
|
||||
'details', 'dialog',
|
||||
'marquee', 'slot',
|
||||
];
|
||||
|
||||
/**
|
||||
* 제거할 속성 목록 (`on*` 이벤트 핸들러는 전부 별도로 제거).
|
||||
*/
|
||||
private const FORBID_ATTRS = [
|
||||
'formaction', 'xlink:href', 'action', 'srcdoc', 'ping',
|
||||
];
|
||||
|
||||
/**
|
||||
* URL 값을 담는 속성 목록 (스킴 검사 대상).
|
||||
*/
|
||||
private const URL_ATTRS = ['href', 'src', 'srcset', 'poster', 'background', 'cite', 'longdesc'];
|
||||
|
||||
/**
|
||||
* URL 속성에 허용하는 스킴.
|
||||
*/
|
||||
private const ALLOWED_SCHEMES = ['http', 'https', 'mailto', 'tel', 'sms', 'ftp', 'cid', 'xmpp', 'callto'];
|
||||
|
||||
/**
|
||||
* `data:` URL 중 허용하는 형태 (이미지 한정 — svg 는 스크립트 실행 벡터라 제외).
|
||||
*/
|
||||
private const ALLOWED_DATA_URL = '/^data:image\/(png|jpe?g|gif|webp|avif|bmp);/i';
|
||||
|
||||
/**
|
||||
* 사용자 작성 HTML 을 정화해 반환합니다.
|
||||
*
|
||||
* 파싱에 실패하면 정화되지 않은 HTML 을 내보내지 않고 전체를 이스케이프합니다
|
||||
* (실패 시 안전한 쪽으로 기웁니다).
|
||||
*
|
||||
* @param string $html 정화할 HTML
|
||||
* @return string 정화된 HTML
|
||||
*/
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
if (trim($html) === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (! class_exists(\DOMDocument::class)) {
|
||||
return e($html);
|
||||
}
|
||||
|
||||
$document = new \DOMDocument('1.0', 'UTF-8');
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
|
||||
try {
|
||||
$loaded = $document->loadHTML(
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><div id="'
|
||||
.self::ROOT_ID.'">'.$html.'</div></body></html>',
|
||||
LIBXML_NOERROR | LIBXML_NOWARNING
|
||||
);
|
||||
|
||||
if (! $loaded) {
|
||||
return e($html);
|
||||
}
|
||||
|
||||
$root = $document->getElementById(self::ROOT_ID);
|
||||
if (! $root instanceof \DOMElement) {
|
||||
return e($html);
|
||||
}
|
||||
|
||||
$this->cleanNode($root);
|
||||
|
||||
return $this->innerHtml($document, $root);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('봇 화면 HTML 정화 실패 — 전체 이스케이프로 대체', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return e($html);
|
||||
} finally {
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드와 그 자손을 재귀적으로 정화합니다.
|
||||
*
|
||||
* @param \DOMNode $node 대상 노드
|
||||
*/
|
||||
private function cleanNode(\DOMNode $node): void
|
||||
{
|
||||
// 자식 목록을 먼저 복사한다 — 순회 중 DOM 을 변경하므로 라이브 NodeList 를 쓰면 항목을 건너뛴다.
|
||||
$children = [];
|
||||
foreach ($node->childNodes as $child) {
|
||||
$children[] = $child;
|
||||
}
|
||||
|
||||
foreach ($children as $child) {
|
||||
if ($child instanceof \DOMElement) {
|
||||
$this->cleanElement($child);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 주석은 봇 화면에 남길 이유가 없고 조건부 주석 등 파서 혼란 요인이 된다.
|
||||
if ($child instanceof \DOMComment) {
|
||||
$child->parentNode?->removeChild($child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 요소를 정화합니다 (제거 / 언랩 / 속성 정리).
|
||||
*
|
||||
* @param \DOMElement $element 대상 요소
|
||||
*/
|
||||
private function cleanElement(\DOMElement $element): void
|
||||
{
|
||||
$tag = strtolower($element->tagName);
|
||||
|
||||
if (in_array($tag, self::DROP_WITH_CONTENT, true)) {
|
||||
$element->parentNode?->removeChild($element);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($tag, self::UNWRAP_KEEP_CONTENT, true)) {
|
||||
$this->cleanNode($element);
|
||||
$this->unwrap($element);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->cleanAttributes($element);
|
||||
$this->cleanNode($element);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소를 제거하고 자식 노드를 부모의 같은 자리에 올립니다.
|
||||
*
|
||||
* @param \DOMElement $element 대상 요소
|
||||
*/
|
||||
private function unwrap(\DOMElement $element): void
|
||||
{
|
||||
$parent = $element->parentNode;
|
||||
if ($parent === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
while ($element->firstChild !== null) {
|
||||
$parent->insertBefore($element->firstChild, $element);
|
||||
}
|
||||
|
||||
$parent->removeChild($element);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소의 속성을 정리합니다.
|
||||
*
|
||||
* @param \DOMElement $element 대상 요소
|
||||
*/
|
||||
private function cleanAttributes(\DOMElement $element): void
|
||||
{
|
||||
$attributes = [];
|
||||
foreach ($element->attributes as $attribute) {
|
||||
$attributes[] = $attribute->nodeName;
|
||||
}
|
||||
|
||||
foreach ($attributes as $name) {
|
||||
$lower = strtolower($name);
|
||||
|
||||
// 이벤트 핸들러 전량 제거 (onerror, onload, onanimationend, ...)
|
||||
if (str_starts_with($lower, 'on')) {
|
||||
$element->removeAttribute($name);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($lower, self::FORBID_ATTRS, true)) {
|
||||
$element->removeAttribute($name);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($lower, self::URL_ATTRS, true)
|
||||
&& ! $this->isAllowedUrl($element->getAttribute($name))) {
|
||||
$element->removeAttribute($name);
|
||||
}
|
||||
}
|
||||
|
||||
// 외부 링크에 rel 보강 (일반 화면의 HtmlContent 후처리와 동일)
|
||||
if (strtolower($element->tagName) === 'a' && ! $element->hasAttribute('rel')) {
|
||||
$href = $element->getAttribute('href');
|
||||
if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) {
|
||||
$element->setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 속성 값이 허용된 스킴인지 판정합니다.
|
||||
*
|
||||
* 상대 경로 · 앵커 · 프로토콜 상대 경로는 허용하고, `javascript:` 처럼 스크립트를
|
||||
* 실행할 수 있는 스킴은 차단합니다.
|
||||
*
|
||||
* @param string $url 검사할 URL
|
||||
* @return bool 허용 여부
|
||||
*/
|
||||
private function isAllowedUrl(string $url): bool
|
||||
{
|
||||
$trimmed = trim($url);
|
||||
|
||||
if ($trimmed === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 제어문자를 끼워 넣어 스킴 검사를 우회하는 시도를 막는다 (`java\tscript:` 등)
|
||||
$normalized = strtolower(preg_replace('/[\x00-\x20]+/', '', $trimmed) ?? '');
|
||||
|
||||
if (preg_match(self::ALLOWED_DATA_URL, $normalized) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 스킴이 없으면 상대 경로 / 앵커 / 쿼리 → 허용
|
||||
if (preg_match('/^([a-z][a-z0-9+.-]*):/', $normalized, $matches) !== 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($matches[1], self::ALLOWED_SCHEMES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소의 내부 HTML 을 문자열로 반환합니다.
|
||||
*
|
||||
* @param \DOMDocument $document 소속 문서
|
||||
* @param \DOMElement $element 대상 요소
|
||||
* @return string 내부 HTML
|
||||
*/
|
||||
private function innerHtml(\DOMDocument $document, \DOMElement $element): string
|
||||
{
|
||||
$html = '';
|
||||
foreach ($element->childNodes as $child) {
|
||||
$html .= $document->saveHTML($child);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
+86
-27
@@ -95,6 +95,39 @@ class SeoRenderer implements SeoRendererInterface
|
||||
|
||||
$queryParams = $request->query();
|
||||
|
||||
// 레이아웃 초기 상태를 프론트엔드(TemplateApp)와 동일한 순서로 반영한다:
|
||||
// 레이아웃 최상위 initLocal/state·initGlobal(없는 키만) → init_actions setState
|
||||
// initActions (camelCase, LayoutService 병합 결과) 또는 init_actions (snake_case, 원본 JSON) 둘 다 지원
|
||||
$initActions = $mergedLayout['initActions'] ?? $mergedLayout['init_actions'] ?? [];
|
||||
|
||||
// 데이터소스 엔드포인트/params 표현식이 참조할 수 있도록 _global 을 먼저 구성한다.
|
||||
// (예: 검색 결과 엔드포인트의 {{_global.searchActiveTab ?? 'all'}})
|
||||
$preContext = [
|
||||
'route' => array_merge($routeParams, ['path' => $url]),
|
||||
'query' => $queryParams,
|
||||
'_global' => $this->buildGlobalContext(),
|
||||
];
|
||||
|
||||
// 레이아웃 최상위 initGlobal → _global (기존 키는 보존)
|
||||
foreach ($this->resolveInitStateBlock($mergedLayout['initGlobal'] ?? [], $preContext) as $key => $value) {
|
||||
if (! array_key_exists($key, $preContext['_global'])) {
|
||||
$preContext['_global'][$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
// init_actions의 setState(target: global) → _global
|
||||
$globalFromActions = $this->resolveInitActionState($initActions, 'global', $preContext);
|
||||
|
||||
// target: global 인 setState가 _local 하위 객체를 싣는 경우 _local로 병합
|
||||
// (프론트엔드 setGlobalState가 globalState._local을 갱신하는 동작과 동일)
|
||||
$localFromGlobalActions = [];
|
||||
if (isset($globalFromActions['_local']) && is_array($globalFromActions['_local'])) {
|
||||
$localFromGlobalActions = $globalFromActions['_local'];
|
||||
unset($globalFromActions['_local']);
|
||||
}
|
||||
|
||||
$preContext['_global'] = array_merge($preContext['_global'], $globalFromActions);
|
||||
|
||||
$context = [];
|
||||
if (! empty($seoDataSourceIds)) {
|
||||
$context = $this->dataSourceResolver->resolve(
|
||||
@@ -102,25 +135,30 @@ class SeoRenderer implements SeoRendererInterface
|
||||
$seoDataSourceIds,
|
||||
$routeParams,
|
||||
$locale,
|
||||
$queryParams
|
||||
$queryParams,
|
||||
$preContext
|
||||
);
|
||||
}
|
||||
|
||||
// route 정보를 context에 추가 (path: 현재 URL 경로, + 동적 파라미터)
|
||||
$context['route'] = array_merge($routeParams, ['path' => $url]);
|
||||
// route/query/_global 을 데이터소스 결과와 합쳐 렌더 컨텍스트를 구성
|
||||
$context['route'] = $preContext['route'];
|
||||
$context['query'] = $preContext['query'];
|
||||
$context['_global'] = $preContext['_global'];
|
||||
|
||||
// query 파라미터도 context에 추가
|
||||
$context['query'] = $queryParams;
|
||||
// 레이아웃 최상위 initLocal (없으면 state — 하위 호환) → _local
|
||||
// _local 은 데이터소스 결과를 참조할 수 있어야 하므로 호출 이후에 평가한다.
|
||||
$local = $this->resolveInitStateBlock(
|
||||
$mergedLayout['initLocal'] ?? $mergedLayout['state'] ?? [],
|
||||
$context
|
||||
);
|
||||
|
||||
// SEO 컨텍스트에 _global/_local 추가
|
||||
// 프론트엔드에서 window.G7Config로 주입되는 설정을 서버사이드에서도 동일하게 제공
|
||||
$context['_global'] = $this->buildGlobalContext();
|
||||
$local = array_merge(
|
||||
$local,
|
||||
$this->resolveInitActionState($initActions, 'local', $context),
|
||||
$localFromGlobalActions
|
||||
);
|
||||
|
||||
// init_actions의 setState(target: local)을 평가하여 _local 초기화
|
||||
// 프론트엔드에서 init_actions로 설정하는 _local 상태를 SEO 렌더링에서도 동일하게 반영
|
||||
// initActions (camelCase, LayoutService 병합 결과) 또는 init_actions (snake_case, 원본 JSON) 둘 다 지원
|
||||
$initActions = $mergedLayout['initActions'] ?? $mergedLayout['init_actions'] ?? [];
|
||||
$context['_local'] = $this->resolveInitLocalState($initActions, $context);
|
||||
$context['_local'] = $local;
|
||||
|
||||
// 5.1. initGlobal 매핑: 데이터소스 결과를 _global 경로에 주입
|
||||
// 프론트엔드에서 data_source의 initGlobal 설정으로 _global에 매핑하는 것과 동일
|
||||
@@ -909,28 +947,52 @@ class SeoRenderer implements SeoRendererInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* init_actions의 setState(target: local)을 평가하여 _local 초기값을 반환합니다.
|
||||
* 레이아웃 최상위 초기 상태 블록(initLocal/state/initGlobal)의 값을 평가합니다.
|
||||
*
|
||||
* 프론트엔드에서 init_actions 실행 시 setState로 설정하는 _local 상태를
|
||||
* 프론트엔드 TemplateApp이 레이아웃 레벨 initLocal/initGlobal을 상태에 적용하는 것과
|
||||
* 동일하게, 각 값의 {{}} 표현식을 해석해 반환합니다.
|
||||
*
|
||||
* @param mixed $block 초기 상태 블록 (키 → 값)
|
||||
* @param array $context 현재 컨텍스트 (route, query 등 포함)
|
||||
* @return array 평가된 초기 상태
|
||||
*/
|
||||
private function resolveInitStateBlock(mixed $block, array $context): array
|
||||
{
|
||||
if (! is_array($block) || $block === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resolved = [];
|
||||
foreach ($block as $key => $value) {
|
||||
$resolved[$key] = $this->resolveInitActionValue($value, $context);
|
||||
}
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* init_actions의 setState를 대상(target)별로 평가하여 초기값을 반환합니다.
|
||||
*
|
||||
* 프론트엔드에서 init_actions 실행 시 setState로 설정하는 _local/_global 상태를
|
||||
* SEO 렌더링에서도 동일하게 반영합니다. 이를 통해 탭 상태, 페이지네이션 초기값 등
|
||||
* _local 기반 조건부 렌더링이 SEO에서도 정상 동작합니다.
|
||||
* 상태 기반 조건부 렌더링이 SEO에서도 정상 동작합니다.
|
||||
*
|
||||
* 처리 대상:
|
||||
* - handler: "setState" + params.target: "local" (또는 target 미지정)
|
||||
* - handler: "setState" + params.target 이 $target 과 일치 (target 미지정은 local)
|
||||
* - params 내 {{}} 표현식을 ExpressionEvaluator로 평가
|
||||
* - 배열 리터럴, 객체 리터럴 등 정적 값은 그대로 사용
|
||||
*
|
||||
* 스킵 대상:
|
||||
* - handler가 setState가 아닌 항목 (loadFromLocalStorage, closeModal 등)
|
||||
* - target이 "global"인 항목
|
||||
*
|
||||
* @param array $initActions 레이아웃의 init_actions 배열
|
||||
* @param string $target 대상 상태 (`local` 또는 `global`)
|
||||
* @param array $context 현재 컨텍스트 (route, query 등 포함)
|
||||
* @return array _local 초기값
|
||||
* @return array 평가된 초기값
|
||||
*/
|
||||
private function resolveInitLocalState(array $initActions, array $context): array
|
||||
private function resolveInitActionState(array $initActions, string $target, array $context): array
|
||||
{
|
||||
$local = [];
|
||||
$state = [];
|
||||
|
||||
// setState에서 제외할 메타 키 (상태 값이 아닌 핸들러 제어용 키)
|
||||
$metaKeys = ['target', 'handler', 'comment'];
|
||||
@@ -942,10 +1004,7 @@ class SeoRenderer implements SeoRendererInterface
|
||||
}
|
||||
|
||||
$params = $action['params'] ?? [];
|
||||
$target = $params['target'] ?? 'local';
|
||||
|
||||
// global 대상은 스킵 (_global은 buildGlobalContext + applyInitGlobalMapping이 담당)
|
||||
if ($target === 'global') {
|
||||
if (($params['target'] ?? 'local') !== $target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -954,11 +1013,11 @@ class SeoRenderer implements SeoRendererInterface
|
||||
continue;
|
||||
}
|
||||
|
||||
$local[$key] = $this->resolveInitActionValue($value, $context);
|
||||
$state[$key] = $this->resolveInitActionValue($value, $context);
|
||||
}
|
||||
}
|
||||
|
||||
return $local;
|
||||
return $state;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 리스너 전 영역에서 키 누락 자동 검출
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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 문서를 함께 갱신하지 않으면 그 변경은 통과하지 못한다. 나머지 확장은 문서
|
||||
완비 시 순차로 차단 대상에 편입된다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 라 런타임 차단은 없고 문서로 안내한다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 를 읽기만 함).
|
||||
|
||||
---
|
||||
|
||||
@@ -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 을 하드코딩한 서드파티 확장이 깨진다.
|
||||
|
||||
+105
-3
@@ -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
|
||||
@@ -937,6 +937,21 @@ config에 없는 컴포넌트는 `<div>` fallback으로 렌더링됩니다.
|
||||
- 좌측이 배열/객체면 원본 타입 유지 (문자열 변환 없음)
|
||||
- 복합 표현식 (`&&`, `||` 포함) 시에는 일반 `evaluate()` 위임
|
||||
|
||||
### ExpressionEvaluator — `||` / `&&` 논리 연산자
|
||||
|
||||
일반 화면과 동일하게 **값을 반환**합니다. `true` / `false` 라는 글자로 바뀌지 않습니다.
|
||||
|
||||
| 표현식 | 좌측 값 | 결과 |
|
||||
|--------|--------|------|
|
||||
| `{{query.q \|\| ''}}` | `"검색어"` | `"검색어"` |
|
||||
| `{{query.q \|\| ''}}` | 빈 값 | `""` |
|
||||
| `{{a \|\| b}}` | `a` 가 빈 값 | `b` 의 값 |
|
||||
| `{{a && b}}` | `a` 가 빈 값 | `a` 의 값 |
|
||||
| `{{a && b}}` | `a` 가 값 있음 | `b` 의 값 |
|
||||
|
||||
- 빈 값 판정: `''`, `'false'`, `'0'`
|
||||
- 이 규칙은 데이터소스 엔드포인트 보간(`?q={{query?.q \|\| ''}}`)에서도 그대로 적용됩니다. 값 대신 `true` 가 들어가면 봇이 엉뚱한 목록을 받게 됩니다.
|
||||
|
||||
### ExpressionEvaluator — 삼항 연산자
|
||||
|
||||
`condition ? trueExpr : falseExpr` 형태의 삼항 연산자를 지원합니다. JS 우선순위에 맞게 `||`/`&&`보다 먼저 분리됩니다.
|
||||
@@ -1039,6 +1054,93 @@ config에 없는 컴포넌트는 `<div>` fallback으로 렌더링됩니다.
|
||||
- `default`: 매칭 없을 때 기본 클래스
|
||||
- 기존 `className`과 병합 가능
|
||||
|
||||
### ComponentHtmlMapper — Extension Point props 해석
|
||||
|
||||
레이아웃의 `extension_point` 노드를 확장이 교체(또는 추가)하면, 호스트가 선언한 `props` 가 주입 컴포넌트의 최상위 키 `extensionPointProps` 로 전달됩니다. 주입 컴포넌트는 `{{extensionPointProps.content}}` 형태로 그 값을 참조합니다.
|
||||
|
||||
SEO 렌더링은 `renderComponent()` 진입 시점에 이 값을 해석해 데이터 컨텍스트의 `extensionPointProps` 에 넣습니다.
|
||||
|
||||
- 해석 시점: `responsive` 병합·`if` 판정·`iteration` 전개보다 **먼저** — 조건식과 반복 소스도 이 값을 참조할 수 있어야 하기 때문입니다.
|
||||
- 적용 범위: 해당 노드와 **자손 전체**. 형제 노드에는 전달되지 않습니다.
|
||||
- 반복 내부: 반복 항목마다 재해석하지 않고 바깥 컨텍스트 기준으로 한 번만 해석한 뒤 상속합니다.
|
||||
- 값 해석: 문자열 표현식은 원본 타입을 유지한 채 해석됩니다(불리언·숫자·배열 보존). 중첩 객체는 재귀 해석합니다.
|
||||
|
||||
`extensionPointCallbacks` 는 **의도적으로 해석하지 않습니다**. SEO 파이프라인에는 액션 실행기가 없고, 액션 정의 배열을 컨텍스트에 넣으면 HTML 에 직렬화 파편이 노출될 수 있습니다. 봇에게 보여야 할 내용을 콜백 경유로 만들지 마세요.
|
||||
|
||||
미해석 시 증상: 확장이 교체한 본문 영역이 내용 없는 빈 요소(`<div></div>`)로 출력되고, 메타 태그에는 본문이 들어가 **메타와 화면 본문이 어긋납니다**.
|
||||
|
||||
호스트가 넘기는 판정 값(예: `isHtml`)은 비교식으로 쓰이는 경우가 많습니다(`{{(post.data?.content_mode ?? 'text') === 'html'}}`). 비교·논리 연산의 결과는 참/거짓 값으로 해석되므로 주입 컴포넌트의 `{{extensionPointProps.isHtml ?? true}}` 같은 기본값 폴백이 의도대로 동작합니다.
|
||||
|
||||
### 봇 화면의 HTML 정화
|
||||
|
||||
사용자가 작성한 본문(게시글·답변글·페이지·상품 설명)은 **일반 화면과 같은 강도로 정화한 뒤** 봇 화면에 넣습니다. 정화를 생략하면 봇 화면에서만 저장된 스크립트가 살아남아, 주소에 봇 파라미터를 붙이는 것만으로 실행됩니다.
|
||||
|
||||
판정 규칙은 일반 화면의 `HtmlContent` 컴포지트와 같습니다.
|
||||
|
||||
| `isHtml` | 봇 화면 출력 | 일반 화면 |
|
||||
|---|---|---|
|
||||
| `false` | 전체 이스케이프 — 태그가 글자로 보입니다 | 동일 (평문 렌더링) |
|
||||
| `true` 또는 미지정 | 위험 요소를 제거한 뒤 HTML 로 출력 | 동일 (DOMPurify) |
|
||||
|
||||
정화 규칙 (`app/Seo/HtmlSanitizer.php`)
|
||||
|
||||
- 스크립트 실행·외부 콘텐츠 삽입·문서 구조 조작 태그는 제거합니다. 본문 텍스트를 품을 수 있는 태그는 요소만 벗기고 글자는 남깁니다.
|
||||
- `on*` 이벤트 핸들러 속성은 전부 제거합니다(목록에 없는 신규 이벤트 포함).
|
||||
- `href`/`src` 등 URL 속성은 허용 스킴만 남깁니다. `javascript:` 는 제어문자를 끼워 넣은 우회 형태까지 차단하고, `data:` 는 이미지 형식만 허용합니다.
|
||||
- 외부 링크에는 `rel="noopener noreferrer"` 를 보강합니다.
|
||||
- 파싱에 실패하면 정화되지 않은 HTML 을 내보내지 않고 전체를 이스케이프합니다.
|
||||
|
||||
차단 목록의 기준은 일반 화면 컴포넌트의 DOMPurify 설정입니다(`templates/_bundled/sirsoft-basic/src/components/composite/HtmlContent.tsx`). **한쪽만 바꾸면 두 화면의 정화 강도가 어긋나므로 함께 갱신**하세요. 계약은 `tests/Unit/Seo/HtmlSanitizerTest.php` 와 `tests/Unit/Seo/ExtensionPointPropsRenderingTest.php` 가 잠급니다.
|
||||
|
||||
`purifyConfig` prop(일반 화면의 DOMPurify 설정 오버라이드)은 봇 화면에서 해석하지 않습니다 — 기본 정화 규칙만 적용됩니다.
|
||||
|
||||
### 데이터소스 화이트리스트
|
||||
|
||||
봇 렌더링은 `meta.seo.data_sources` 에 적힌 id 만 미리 조회합니다. **화면이 쓰는 데이터소스는 빠짐없이 선언**하세요. 빠지면 그 값은 늘 비어 있고, 그 값을 조건으로 삼는 블록이 통째로 사라져 머리말과 꼬리말만 있는 화면이 색인됩니다.
|
||||
|
||||
봇에게 의미가 없어 일부러 제외하는 경우(브라우저 저장값이 필요한 목록, 로그인 사용자 전용 데이터 등)는 `tests/Unit/Seo/SeoLayoutDataSourceDeclarationTest.php` 의 면제 목록에 사유와 함께 등록합니다. 그 테스트가 번들 템플릿 레이아웃을 전수 순회해 미선언 참조를 차단합니다.
|
||||
|
||||
### SEO 렌더러 지원 노드 키 (SSoT)
|
||||
|
||||
일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃 JSON 을 각각 렌더합니다. 한쪽에만 기능을 추가하면 봇 화면에서 그 부분이 조용히 사라지므로, 지원 범위를 아래 표로 고정합니다. 이 표가 두 렌더러 지원 범위의 단일 기준(SSoT)입니다.
|
||||
|
||||
"봇 화면 처리 위치" 는 파일 경로 + 메서드로 적습니다. 줄 번호는 리팩터링마다 어긋나 오히려 잘못된 근거가 되므로 넣지 않습니다 — 메서드명으로 찾으세요.
|
||||
|
||||
| 노드 키 | 일반 화면 | 봇 화면 | 봇 화면 처리 위치 | 비고 |
|
||||
|---------|----------|--------|------------------|------|
|
||||
| `name` / `type` / `props` / `children` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::renderComponent` / `renderTag` / `renderChildren` | |
|
||||
| `text` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::resolveNodeText` | `children` 보다 우선 (양쪽 동일). 키가 있으면 값이 비어도 `children` 으로 폴백하지 않음. `true`/`false`/`null` 로 평가되면 아무것도 출력하지 않음 (JSX 시맨틱). 단, 자체 렌더링을 가진 집합 컴포넌트(아래 `render` 모드)에서는 컴포넌트 출력이 먼저다 — 일반 화면에서도 `Select` 는 `options` 로 스스로 그리고 `text` 를 쓰지 않는다 |
|
||||
| children 배열 내 문자열 | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::render` | 이스케이프만 하고 리터럴 출력 — 표현식·번역 미해석 (React 동일) |
|
||||
| `if` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::evaluateBooleanExpression` | 거짓 판정: `''`, `false`, `0`, `null`, `undefined` (대소문자·공백 무시) |
|
||||
| `condition` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::shouldRender` | `if` 의 별칭 |
|
||||
| `conditions` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::evaluateConditions` | 문자열 / `{and:[]}` / `{or:[]}` / `[{if:…}]` 체인. 빈 AND=참, 빈 OR=거짓. 어느 형식도 아니면 **렌더링**(양쪽 동일 — 숨기면 봇 화면에서만 사라짐) |
|
||||
| `iteration` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::renderIteration` | `item_var` / `index_var` 별칭 포함 |
|
||||
| `type: "iterator"` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::normalizeIteratorNode` | `data`/`itemName`/`indexName` → `iteration` 변환 |
|
||||
| `classMap` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::resolveClassMap` | |
|
||||
| `responsive` | 전체 브레이크포인트 | 데스크톱 폭만 | `app/Seo/ComponentHtmlMapper.php::applyResponsiveOverrides` / `matchingBreakpointKey` | 봇=데스크톱 고정. `props`/`if`/`text`/`children`/`iteration` 오버라이드 반영. 매칭 키가 여럿이면 **하나만** 적용 — 커스텀 범위 > 프리셋, 좁은 범위 > 넓은 범위 (양쪽 동일) |
|
||||
| `extensionPointProps` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::injectExtensionPointContext` | 자손 상속 |
|
||||
| `default` (extension_point 호스트) | ✅ | ✅ | 렌더 전 단계에서 처리 | 확장이 꺼져 있을 때 쓰는 기본 children — 주입 단계에서 교체·전개되어 렌더러에는 남지 않음 |
|
||||
| `callbacks` (extension_point 호스트) | ✅ | ❌ (의도) | 미처리 | 주입 단계에서 `extensionPointCallbacks` 로 부착 — 봇 화면에는 액션 실행기가 없음 |
|
||||
| `computed` / `extends` / 파셜 | ✅ | ✅ | `app/Seo/SeoRenderer.php::resolveComputed` / `app/Services/LayoutService.php::getLayout` | 레이아웃 병합·설치 시점에 처리 |
|
||||
| `$t:` / `$t:defer:` | ✅ | ✅ | `app/Seo/ExpressionEvaluator.php::resolveTranslation` | 봇 화면은 지연 개념이 없어 동일 키로 해석 |
|
||||
| `actions` | ✅ | 링크만 | `app/Seo/ComponentHtmlMapper.php::extractLinkAction` | 페이지 이동/새 창 열기 액션만 `<a href>` 로 승격 |
|
||||
| `extensionPointCallbacks` | ✅ | ❌ (의도) | 미처리 | 액션 실행기 부재 |
|
||||
| `lifecycle` / `dataKey` / `trackChanges` | ✅ | ❌ (무해) | 미처리 | 입력·생명주기 전용 |
|
||||
| `slot` / `slotOrder` | ✅ | ❌ (무해) | `app/Services/LayoutService.php::replaceSlots` 에서 선처리 | 레이아웃 병합 단계에서 제거 |
|
||||
| `sortable` / `itemTemplate` / `expandChildren` / `component_layout` | ✅ | ❌ (무해) | 미처리 | 조작 후 노출되는 화면 |
|
||||
| `isolatedState` / `$parent` / `_isolated` | ✅ | ❌ (무해) | 미처리 | 격리·모달 부모 컨텍스트 |
|
||||
| `blur_until_loaded` / 노드 최상위 `style` | ✅ | ❌ (무해) | 미처리 | 표현 전용 |
|
||||
| 노드 최상위에 잘못 놓인 컴포넌트 prop (`size` 등) | ❌ | ❌ | 미처리 | 양쪽 모두 `props` 객체만 읽으므로 무시됨 — 값을 적용하려면 `props` 안으로 옮겨야 함 |
|
||||
| `props.isHtml` (콘텐츠 노드) | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::renderRawMode` | 거짓이면 이스케이프, 참(기본)이면 정화 후 HTML — "봇 화면의 HTML 정화" 참조 |
|
||||
| `props.value` (폼 제어) | 속성 | 속성 | `app/Seo/ComponentHtmlMapper.php::resolveTextContent` | `select`/`option`/`input`/`textarea` 등에서는 글자로 승계하지 않음 — 선택 목록은 `options` 로 항목 라벨을 그림 |
|
||||
| `props.purifyConfig` | ✅ | ❌ (의도) | 미처리 | 봇 화면은 기본 정화 규칙만 적용 |
|
||||
| `modals` | ✅ | ❌ (무해) | `SeoRenderer` 가 `components` 만 렌더 | 봇 화면은 모달을 렌더하지 않음 |
|
||||
| 레이아웃 최상위 `state` / `initLocal` / `initGlobal` | ✅ | ✅ | `app/Seo/SeoRenderer.php::resolveInitStateBlock` / `resolveInitActionState` | `init_actions` 의 상태 설정(로컬/전역)도 반영 |
|
||||
|
||||
이 표는 `tests/Unit/Seo/SeoNodeKeyParityTest.php` 의 분류 목록과 동기 유지합니다. 표를 바꾸면 그 테스트의 목록도 함께 바꿔야 합니다. 반대로 레이아웃에 새 노드 키가 등장하면 그 테스트가 실패하므로, 봇 화면에서 해석이 필요한지 판단한 뒤 양쪽을 갱신하세요.
|
||||
|
||||
확장 포인트 쪽 서술은 [layout-extensions.md "Extension Point 데이터 전달"](../extension/layout-extensions.md), 레이아웃 작성자 관점 요약은 [layout-json.md](../frontend/layout-json.md) 를 참조하세요.
|
||||
|
||||
### DataSourceResolver — params 쿼리 파라미터 해석
|
||||
|
||||
data_source 정의에 `params` 필드가 있으면 해당 값을 해석하여 API 호출 시 쿼리 파라미터로 전달합니다.
|
||||
@@ -1081,9 +1183,9 @@ data_source 정의에 `params` 필드가 있으면 해당 값을 해석하여 AP
|
||||
|
||||
| 타입 | 역할 | 설명 |
|
||||
|------|------|------|
|
||||
| `iterate` | 배열 데이터 순회 → 아이템별 HTML 생성 | `item_tag`, `item_attrs`, `item_content`, `badge_field` |
|
||||
| `iterate` | 배열 데이터 순회 → 아이템별 HTML 생성 | `item_tag`, `item_attrs`, `item_content`, `badge_field`. `item_attrs` 와 `item_content` 를 함께 선언하면 속성과 라벨을 모두 그립니다(`<option value="…">라벨</option>`) |
|
||||
| `format` | 포맷 문자열 `{key}` 플레이스홀더 치환 | `format`, `defaults` (component_map 엔트리에서 정의) |
|
||||
| `raw` | 원본 HTML/텍스트 그대로 출력 | `source` (이스케이프 없음) |
|
||||
| `raw` | 사용자 작성 콘텐츠 출력 | `source`. `isHtml` prop 판정에 따라 이스케이프 또는 정화 후 출력 — "봇 화면의 HTML 정화" 참조 |
|
||||
| `fields` | 객체 prop에서 필드 추출 → 개별 HTML 생성 | `fields` (컴포지트 컴포넌트 SEO 렌더링용) |
|
||||
| `pagination` | 페이지네이션 링크 생성 | `max_links` (기본 10), 현재 페이지 `<span>` + 나머지 `<a href="?page=N">` |
|
||||
|
||||
|
||||
@@ -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` 호출 없음
|
||||
|
||||
@@ -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` 필터를 발화하므로 정적 검사 결과는 동일.
|
||||
|
||||
## 참고
|
||||
|
||||
|
||||
@@ -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 는 면제.
|
||||
|
||||
|
||||
@@ -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 미발생.
|
||||
|
||||
#### 검증 절차
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ public function register(): void
|
||||
}
|
||||
```
|
||||
|
||||
audit 룰 `extension-no-global-cache-rebind` 가 자동 차단한다. 면제 표지 불허.
|
||||
정적 검사가 자동 차단한다. 면제 표지 불허.
|
||||
|
||||
### 표준 패턴 — contextual binding
|
||||
|
||||
|
||||
@@ -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/**`)는 별도 검사가 담당하므로 본 절의 대상이 아니다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 인라인 권장)
|
||||
|
||||
|
||||
@@ -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 변환)
|
||||
|
||||
@@ -69,7 +69,7 @@ lang/
|
||||
- `core.<영역>.*` — 향후 코어 UI 추가 시
|
||||
|
||||
호스팅 템플릿/모듈/플러그인의 lang JSON 은 root 에 `core` 키를 정의하지 않는다.
|
||||
audit 룰 `core-frontend-i18n-location` 이 정적으로 차단한다.
|
||||
정적 검사가 차단한다.
|
||||
|
||||
### 런타임 병합 흐름
|
||||
|
||||
|
||||
@@ -363,6 +363,23 @@ v1.17.0 이전: modals 내부의 extension_point는 처리되지 않았음
|
||||
| `props` | object | 주입 컴포넌트에 전달할 데이터. 표현식 평가됨. 플러그인에서 `{{extensionPointProps.xxx}}`로 접근 |
|
||||
| `callbacks` | object | 주입 컴포넌트에 전달할 액션 객체. 평가 없이 그대로 전달. 플러그인에서 `{{extensionPointCallbacks.xxx}}`로 접근 |
|
||||
|
||||
전달된 값은 주입 컴포넌트와 그 **자손 전체**에서 참조할 수 있습니다.
|
||||
|
||||
#### 검색 봇 화면에서의 동작
|
||||
|
||||
일반 화면과 봇용 화면(SEO)은 같은 레이아웃을 각각 렌더합니다. 확장 포인트 데이터 전달의 지원 범위는 두 화면이 다릅니다. 봇 화면의 `props` 해석은 **7.0.6** 부터 동작합니다 — 그 이전 버전에서는 확장이 교체한 영역이 봇에게 빈 요소로 나갔습니다.
|
||||
|
||||
| 항목 | 일반 화면 | 봇 화면 |
|
||||
|------|----------|--------|
|
||||
| `{{extensionPointProps.xxx}}` | 해석됨 | 해석됨 |
|
||||
| `{{extensionPointCallbacks.xxx}}` | 해석됨 | **해석되지 않음** |
|
||||
|
||||
봇 화면에는 액션 실행기가 없으므로 콜백은 의도적으로 전달하지 않습니다. **검색 결과에 노출되어야 할 내용을 콜백 경유로 만들지 마세요** — 봇에게는 빈 값이 됩니다. 본문·제목처럼 색인되어야 하는 값은 `props` 로 전달합니다.
|
||||
|
||||
사용자 작성 콘텐츠를 넘기는 확장 포인트라면 콘텐츠와 함께 **평문/HTML 판정 값(`isHtml`)도 전달**하세요. 두 화면 모두 이 값에 따라 평문으로 이스케이프하거나 위험 요소를 제거한 뒤 출력합니다. 판정 값을 넘기지 않으면 HTML 로 간주됩니다.
|
||||
|
||||
봇 화면의 노드 문법 지원 범위 전체는 [seo-system.md "SEO 렌더러 지원 노드 키"](../backend/seo-system.md)를, 정화 규칙은 같은 문서의 "봇 화면의 HTML 정화" 를 참조하세요.
|
||||
|
||||
### 모듈에서 Extension Point에 주입
|
||||
|
||||
```json
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 같은 멀티 점프에서도 동일한 결과를 보장한다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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+
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -915,6 +915,10 @@ const renderChildren = useMemo(() => {
|
||||
| `props` | object | ❌ | 주입 컴포넌트에 전달할 데이터 (표현식 평가) |
|
||||
| `callbacks` | object | ❌ | 주입 컴포넌트에 전달할 액션 객체 (평가 없이 전달) |
|
||||
|
||||
전달된 값은 주입 컴포넌트와 그 자손 전체에서 참조할 수 있습니다.
|
||||
|
||||
검색 봇용 화면에서는 `extensionPointProps` 만 해석되고 `extensionPointCallbacks` 는 해석되지 않습니다. 색인되어야 할 내용은 `props` 로 전달하세요. 사용자 작성 콘텐츠를 넘길 때는 평문/HTML 판정 값(`isHtml`)을 함께 전달합니다 — 두 화면 모두 이 값에 따라 이스케이프하거나 위험 요소를 제거한 뒤 출력합니다. 봇 화면의 노드 문법 지원 범위는 [seo-system.md "SEO 렌더러 지원 노드 키"](../backend/seo-system.md), 정화 규칙은 같은 문서의 "봇 화면의 HTML 정화" 를 참조하세요.
|
||||
|
||||
---
|
||||
|
||||
## Deprecated 속성
|
||||
|
||||
@@ -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 폭발 관리
|
||||
|
||||
|
||||
@@ -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) 과 정합 — 다른 관리자 화면과 같은 결로 통일.
|
||||
|
||||
+6
-2
@@ -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": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+3
-1
@@ -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이 허용되어 같은 화면에서 항목마다 동작이 달랐습니다.
|
||||
|
||||
+347
@@ -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": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+19
-6
@@ -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": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+7
-3
@@ -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": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+11
-6
@@ -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",
|
||||
|
||||
+15
-4
@@ -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": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+3
-1
@@ -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": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+2
-2
@@ -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" } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+79
@@ -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');
|
||||
});
|
||||
});
|
||||
+156
@@ -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 님께서 제보해주셨습니다.)
|
||||
- 상품을 장바구니에 담거나 바로 구매할 때, 표시 언어(로케일)나 관리자의 옵션 이름 수정과 무관하게 선택한 옵션이 정확히 담기도록 개선했습니다. 특정 상황에서 "해당 옵션 조합을 찾을 수 없습니다" 오류로 담기가 실패하던 문제가 해소됩니다.
|
||||
- 오류 안내가 뜨기는 하지만 내용이 비어 있던 문제를 수정했습니다. 서버가 알려 준 실패 사유가 그대로 표시됩니다.
|
||||
- 헤더에서 통화나 배송 국가를 바꿀 때 저장에 실패해도 아무 안내 없이 넘어가던 문제를 수정했습니다. 실패하면 그 이유가 화면에 표시됩니다.
|
||||
|
||||
+344
@@ -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');
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -144,7 +144,8 @@
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/ecommerce/brands",
|
||||
"mergeQuery": false
|
||||
"mergeQuery": true,
|
||||
"query": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+3
-1
@@ -318,7 +318,9 @@
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/ecommerce/orders"
|
||||
"path": "/admin/ecommerce/orders",
|
||||
"mergeQuery": true,
|
||||
"query": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+2
-1
@@ -185,7 +185,8 @@
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/ecommerce/orders",
|
||||
"mergeQuery": false
|
||||
"mergeQuery": true,
|
||||
"query": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+5
-5
@@ -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": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+2
-1
@@ -122,7 +122,8 @@
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/ecommerce/common-info",
|
||||
"mergeQuery": false
|
||||
"mergeQuery": true,
|
||||
"query": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+7
-2
@@ -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": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+3
-1
@@ -424,7 +424,9 @@
|
||||
"type": "click",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/ecommerce/products/create"
|
||||
"path": "/admin/ecommerce/products/create",
|
||||
"mergeQuery": true,
|
||||
"query": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+2
-1
@@ -134,7 +134,8 @@
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/ecommerce/product-notices",
|
||||
"mergeQuery": false
|
||||
"mergeQuery": true,
|
||||
"query": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+24
-19
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+9
-3
@@ -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": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+6
-2
@@ -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": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+1
@@ -348,6 +348,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "audit:allow layout-list-context-navigate-merge-query 탭 전환 — 탭마다 다루는 대상이 달라 이전 탭의 목록 상태를 물려받으면 오히려 잘못된 화면이 된다.",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/ecommerce/settings",
|
||||
|
||||
+6
-2
@@ -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": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+7
-4
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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"
|
||||
|
||||
+2
@@ -89,8 +89,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "선택 상태만 주소에 기록한다 — 왼쪽 목록에 걸어둔 조건은 그대로 둔다 (#75).",
|
||||
"handler": "replaceUrl",
|
||||
"params": {
|
||||
"mergeQuery": true,
|
||||
"query": {
|
||||
"id": "{{$args[0]?.id}}",
|
||||
"mode": "view"
|
||||
|
||||
+4
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
+3
@@ -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"
|
||||
|
||||
+2
@@ -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",
|
||||
|
||||
+19
-7
@@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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",
|
||||
|
||||
+2
@@ -241,8 +241,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "선택 상태만 주소에 기록한다 — 왼쪽 목록에 걸어둔 검색어는 그대로 둔다 (#75).",
|
||||
"handler": "replaceUrl",
|
||||
"params": {
|
||||
"mergeQuery": true,
|
||||
"query": {
|
||||
"id": "{{item.id}}",
|
||||
"mode": "view"
|
||||
|
||||
+3
-1
@@ -271,7 +271,9 @@
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/ecommerce/products"
|
||||
"path": "/admin/ecommerce/products",
|
||||
"mergeQuery": true,
|
||||
"query": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+2
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+6
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+2
@@ -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",
|
||||
|
||||
+3
-1
@@ -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": {
|
||||
|
||||
+2
@@ -215,8 +215,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "선택 상태만 주소에 기록한다 — 왼쪽 목록에 걸어둔 검색어는 그대로 둔다 (#75).",
|
||||
"handler": "replaceUrl",
|
||||
"params": {
|
||||
"mergeQuery": true,
|
||||
"query": {
|
||||
"id": "{{template.id}}",
|
||||
"mode": "view"
|
||||
|
||||
+14
-7
@@ -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": {
|
||||
|
||||
+4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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"
|
||||
},
|
||||
|
||||
+1
@@ -255,6 +255,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "audit:allow layout-list-context-navigate-merge-query 탭 전환 — 탭마다 다루는 대상이 달라 이전 탭의 목록 상태를 물려받으면 오히려 잘못된 화면이 된다.",
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/ecommerce/settings",
|
||||
|
||||
+1
@@ -247,6 +247,7 @@
|
||||
"handler": "navigate",
|
||||
"params": {
|
||||
"path": "/admin/ecommerce/shipping-policies/create",
|
||||
"mergeQuery": true,
|
||||
"query": {
|
||||
"copy_id": "{{_global.targetPolicy?.id}}"
|
||||
}
|
||||
|
||||
+3
-1
@@ -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": {
|
||||
|
||||
+3
-1
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -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');
|
||||
});
|
||||
});
|
||||
+147
@@ -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 로 흡수돼 비활성 브랜드가 새어 나왔습니다.');
|
||||
}
|
||||
}
|
||||
+31
@@ -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 님께서 제보해주셨습니다.)
|
||||
- 페이지 활동 로그에서 "제목"·"내용" 변경 항목의 이름이 비어 보이던 문제를 수정했습니다.
|
||||
- 다른 페이지의 주소로 특정 페이지의 이전 버전 내용을 열람할 수 있던 문제를 수정했습니다.
|
||||
- 다른 페이지에 속한 버전으로 복원을 시도하면 "복원에 실패했습니다" 라는 서버 오류가 나던 문제를 수정했습니다. 이제 열람과 동일하게 "찾을 수 없음" 으로 안내합니다.
|
||||
|
||||
+212
@@ -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": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
Loaded 100 of 213 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user