현금영수증/환불계좌 기능(S3)의 감사 보완과, 그 과정에서 발견한 하네스 결함을 같은 세션에서 일괄 처리한다. 지시에 따라 무관 산출물(namuwiki 문서 초안 포함)도 단일 커밋으로 묶는다. - 현금영수증 발급 이력 접기/펼치기(W-3) + 결제행별 독립 토글 + aria 상태 - vendor-bundle manifest 해시 재생성 — composer.json version bump 미반영분 정합 - 시나리오 매니페스트 거짓 경로 정정(A/B) + 미작성 부채 :test-files 정직 표기(C) - layout-* 룰의 resources/extensions 검사 사각 제거 + 확장점 전용 root 스키마 분기 - validate-test-scenario.cjs: 맵형 test_files 크래시 + fallback 파서 covers 오검출 수정 - tosspayments 확장점 setState target 규약 위반 수정
891 lines
36 KiB
Markdown
891 lines
36 KiB
Markdown
# 레이아웃 확장 시스템 (Layout Extensions)
|
||
|
||
> 모듈/플러그인이 기존 레이아웃에 동적으로 UI를 주입하는 시스템입니다.
|
||
|
||
## 목차
|
||
|
||
- [개요](#개요)
|
||
- [핵심 원칙](#핵심-원칙)
|
||
- [확장 타입](#확장-타입)
|
||
- [Extension 파일 위치](#extension-파일-위치)
|
||
- [Overlay 확장](#overlay-확장)
|
||
- [Extension Point 확장](#extension-point-확장)
|
||
- [템플릿 오버라이드](#템플릿-오버라이드)
|
||
- [모듈 활성화/비활성화 동작](#모듈-활성화비활성화-동작)
|
||
- [우선순위](#우선순위)
|
||
- [데이터 소스 병합](#데이터-소스-병합)
|
||
- [모달 병합](#모달-병합)
|
||
- [Overlay 레이아웃 섹션 병합](#overlay-레이아웃-섹션-병합)
|
||
- [관련 파일](#관련-파일)
|
||
- [문제 해결](#문제-해결)
|
||
|
||
---
|
||
|
||
## 개요
|
||
|
||
Layout Extension 시스템은 모듈/플러그인이 코어 또는 다른 확장의 레이아웃에 UI 컴포넌트를 동적으로 주입할 수 있게 해줍니다.
|
||
|
||
**주요 특징**:
|
||
- 코어 레이아웃 수정 없이 UI 확장
|
||
- 모듈 비활성화 시 자동으로 확장 UI 숨김
|
||
- 템플릿에서 모듈 확장 오버라이드 가능
|
||
- 우선순위 기반 렌더링 순서 제어
|
||
- 관리자가 레이아웃 시각 편집기로 확장 조각을 직접 편집 (아래 "시각 편집" 참조)
|
||
|
||
### 시각 편집 (레이아웃 편집기)
|
||
|
||
레이아웃 시각 편집기(`/admin/layout-editor/{templateId}`)의 라우트 트리에 `[확장 주입]`
|
||
그룹(출처별 하위그룹)이 표시되며, 확장 항목을 선택하면 확장 편집 모드로 진입해 확장 조각
|
||
(extension_point=`content.components`, overlay=각 injection 의 `components`)을 일반 레이아웃과
|
||
동등하게 시각 편집한다. 저장은 해당 확장 행(layout-extensions API)으로 적재되어 버전 관리·수정
|
||
감지·`--layout-strategy=keep` 보존이 동작한다.
|
||
|
||
`inject_props` 주입은 편집할 컴포넌트 트리가 없으므로, 그 호스트 노드를 라우트/공통 레이아웃
|
||
편집 중 선택해 **속성 모달의 "확장이 주입한 속성" 섹션**(출처 배지)에서 편집한다 — 편집 결과는
|
||
호스트 레이아웃이 아니라 그 확장 행으로 교차 저장된다. 라우트/공통 레이아웃 편집 중 확장이 주입한
|
||
영역은 잠금 표시되며 "확장 편집" 어포던스로 확장 편집 모드에 진입할 수 있다.
|
||
|
||
#### 주입 kind × 편집기 대응
|
||
|
||
확장이 호스트에 주입하는 모든 kind 와 편집기의 대응 방식은 아래 표가 기준이다. 새 주입 방법을
|
||
추가할 때는 이 표에 편집기 대응을 함께 정의해야 한다.
|
||
|
||
| 주입 kind | 주입 경로 | 편집기 대응 |
|
||
| --- | --- | --- |
|
||
| extension_point `components` (mode: replace/append/prepend) | 호스트 EP 노드 자리 (components·modals 트리 모두 순회) | 확장 편집 모드 시각 편집. 게이트(`if`) 뒤면 editor-spec `states` 로 상태 시뮬레이션, 미정의 시 디그레이드 안내 |
|
||
| overlay `injections[].components` (position: prepend/append/prepend_child/append_child/replace) | 호스트 `target_id` 노드 기준 | 동일 — 확장 편집 모드 시각 편집 |
|
||
| overlay `injections[].position: inject_props` | 호스트 `target_id` 노드 props 병합 | 라우트/공통 편집 중 속성 모달 "확장이 주입한 속성" 섹션에서 편집 (확장 행으로 교차 저장) |
|
||
| 호스트 **모달 내부** EP 주입 (예: 회원가입 약관 모달의 `html_content`) | 호스트 `modals[]` 안 EP 노드 | 확장 편집 모드가 그 모달 노드를 표시용 `isOpen=true` 로 캔버스에 함께 렌더해 시각 편집 (모달 편집 모드와 동형) |
|
||
| `content.modals` (확장이 모달을 통째로 기여) | 호스트 `modals[]` 끝에 병합 | 시각 편집 비대상 — 트리에는 노출되고, 진입 시 "코드 편집 이용" 디그레이드 안내 |
|
||
| `content.data_sources` | 호스트 `data_sources` 병합 | 데이터 소스 모달에 출처 배지로 표시 (시각 편집 비대상) |
|
||
| `content.scripts` | 호스트 `scripts` 병합 (중복 제거) | 캔버스에 로드만 (CKEditor CDN 등) — 편집은 코드 편집 |
|
||
| `content.init_actions` (overlay) | 호스트 `init_actions` 끝에 병합 | 시각 편집 비대상 — 진입 시 "코드 편집 이용" 디그레이드 안내 |
|
||
|
||
트리 노출 판별(백엔드 `getExtensionHostLayouts`)은 "주입이 성립하는가"(overlay: 실재 `target_id`
|
||
injection 또는 modals/data_sources/scripts/init_actions 보유, EP: 병합할 payload 보유 + 확장점 실재)만 본다.
|
||
시각 편집 가능 여부의 정밀 판정은 진입 시 프론트(editability 판정 + 렌더 검증 + 폴백 안내)가
|
||
수행한다 — 시각 조각이 없는 확장도 숨기지 않고 디그레이드 안내로 코드 편집을 유도한다.
|
||
|
||
---
|
||
|
||
## 핵심 원칙
|
||
|
||
```text
|
||
필수: layout_extensions 통한 동적 주입 사용 (코어 레이아웃에 모듈 UI 하드코딩 금지)
|
||
필수: Layout Extension 시스템을 통한 동적 UI 주입
|
||
필수: 모듈 비활성화 시 관련 UI 자동 숨김
|
||
✅ 필수: 확장 컴포넌트에 ExtensionBadge 표시 (관리자 UI)
|
||
필수: 플러그인이 자기 화면을 가질 때 라우트는 plugins/{identifier}/ 네임스페이스 안에 선언
|
||
(다른 확장이 소유한 경로를 선언하면 설치 순서에 따라 화면이 조용히 바뀐다)
|
||
필수: 다른 확장의 화면에 UI 를 끼워 넣을 때는 확장 지점(layout_extensions) 사용
|
||
```
|
||
|
||
---
|
||
|
||
## 확장 타입
|
||
|
||
| 타입 | 키 | 설명 | 사용 시점 |
|
||
|------|-----|------|----------|
|
||
| **Overlay** | `target_layout` | 기존 레이아웃의 특정 컴포넌트 ID를 찾아 주입 | 특정 위치에 삽입/추가 |
|
||
| **Extension Point** | `extension_point` | 레이아웃에 사전 정의된 확장 포인트에 주입 | 확장용 영역이 미리 정의된 경우 |
|
||
|
||
---
|
||
|
||
## Extension 파일 위치
|
||
|
||
### 모듈
|
||
|
||
```
|
||
modules/
|
||
└── vendor-module/
|
||
└── resources/
|
||
└── extensions/ ← 확장 정의 디렉토리
|
||
└── *.json ← 확장 JSON 파일
|
||
```
|
||
|
||
### 플러그인
|
||
|
||
```
|
||
plugins/
|
||
└── vendor-plugin/
|
||
└── resources/
|
||
└── extensions/
|
||
└── *.json
|
||
```
|
||
|
||
### 템플릿 오버라이드
|
||
|
||
```
|
||
templates/
|
||
└── vendor-template/
|
||
└── extensions/
|
||
└── {module-identifier}/ ← 오버라이드 대상 모듈
|
||
└── *.json ← 오버라이드 JSON
|
||
```
|
||
|
||
---
|
||
|
||
## Overlay 확장
|
||
|
||
기존 레이아웃의 특정 컴포넌트를 찾아 UI를 주입합니다.
|
||
|
||
### JSON 스키마
|
||
|
||
```json
|
||
{
|
||
"target_layout": "admin_user_form",
|
||
"injections": [
|
||
{
|
||
"target_id": "section_marketing_consent",
|
||
"position": "append",
|
||
"components": [...]
|
||
}
|
||
],
|
||
"data_sources": [...],
|
||
"priority": 100
|
||
}
|
||
```
|
||
|
||
### 필드 설명
|
||
|
||
| 필드 | 필수 | 타입 | 설명 |
|
||
|------|------|------|------|
|
||
| `target_layout` | ✅ | string | 확장할 대상 레이아웃 이름 |
|
||
| `injections` | ✅ | array | 주입 정의 배열 |
|
||
| `injections[].target_id` | ✅ | string | 주입 대상 컴포넌트 ID |
|
||
| `injections[].position` | ✅ | string | 주입 위치 (아래 참조) |
|
||
| `injections[].components` | ✅ | array | 주입할 컴포넌트 배열 |
|
||
| `data_sources` | ❌ | array | 추가 데이터 소스 (선택) |
|
||
| `init_actions` | ❌ | array | 호스트 레이아웃 `init_actions` 뒤에 추가할 초기화 액션 배열 (선택). 모듈이 호스트가 모르는 `_global` 초기화(예: 모듈별 표시 통화 복원)를 기여할 때 사용. 호스트는 추가 단계의 존재/모듈을 모른다 |
|
||
| `priority` | ❌ | number | 우선순위 (기본값: 100, 낮을수록 먼저) |
|
||
|
||
> `init_actions` 병합은 **overlay(`target_layout`)** 확장에서 지원한다. 호스트 `init_actions` 배열 끝에 확장의 `init_actions` 가 순서대로 추가되므로, 호스트 초기화가 모두 끝난 뒤 확장 초기화가 실행된다. 모듈 비활성 시 그 확장 행 자체가 비활성화되어 추가 단계도 사라진다(호스트 레이아웃 수정 0). 모듈이 호출하는 핸들러는 모듈 소유 네임스페이스(`{module-id}.handlerName`)를 사용해 모듈 미설치 시 미등록 핸들러로 무동작하게 하는 것을 권장한다.
|
||
|
||
### 상속(extends) 체인 전파 — base 레이아웃 타겟 overlay
|
||
|
||
`target_layout` 이 다른 화면이 `extends` 로 상속하는 **공통/베이스 레이아웃**(예: `_user_base`, `_admin_base`)인 overlay 는, 그 base 를 상속하는 모든 자식 레이아웃(`home`, `shop/index` 등)을 서빙할 때도 적용된다.
|
||
|
||
서빙 파이프라인은 `extends` 를 해소해 자식 문서를 만들 때 상속한 base 이름들을 내부 메타로 보존하고, overlay 매칭 시 `[자식 레이아웃명] + 상속 체인` 전체를 대상으로 한다. 따라서 `_user_base` 를 `target_layout` 으로 하는 overlay(예: 헤더 슬롯 주입, `init_actions` 통화 복원)는 base 를 직접 서빙하지 않아도 자식 화면에 정상 주입된다. 다단계 상속(`A extends B extends _user_base`)도 체인 끝까지 전파된다.
|
||
|
||
```text
|
||
overlay(target_layout=_user_base) 주입
|
||
↓ (home extends _user_base 서빙 시)
|
||
home 화면에 _user_base 타겟 overlay 가 적용됨 (슬롯·init_actions 포함)
|
||
```
|
||
|
||
- Extension Point 는 병합된 컴포넌트 트리를 직접 스캔하므로 상속과 무관하게 항상 동작한다(이 전파는 overlay 매칭에만 해당).
|
||
- 같은 overlay 가 중복 적용되지 않도록 매칭 결과는 확장 ID 기준으로 합집합 후 `priority` 로 재정렬된다.
|
||
- 편집기에서 자식 라우트 레이아웃을 편집할 때, base 타겟 overlay 가 주입한 영역은 확장 출처 메타가 부여되어 확장 잠금 영역으로 표시된다(해당 overlay 자체의 편집 호스트는 base 레이아웃).
|
||
|
||
### position 옵션
|
||
|
||
| 값 | 설명 | 다이어그램 |
|
||
|-----|------|----------|
|
||
| `prepend` | 타겟 **앞에** 형제로 삽입 | `[NEW] [TARGET] [siblings...]` |
|
||
| `append` | 타겟 **뒤에** 형제로 삽입 | `[TARGET] [NEW] [siblings...]` |
|
||
| `prepend_child` | 타겟 children **맨 앞에** 삽입 | `TARGET { [NEW] [children...] }` |
|
||
| `append_child` | 타겟 children **맨 뒤에** 삽입 | `TARGET { [children...] [NEW] }` |
|
||
| `replace` | 타겟 **완전 교체** | `[NEW]` (기존 타겟 제거) |
|
||
| `inject_props` | 타겟 컴포넌트의 **props에 값 주입** | `TARGET.props ← injection.props` |
|
||
|
||
### inject_props 상세
|
||
|
||
`inject_props`는 기존 컴포넌트의 props에 값을 주입하는 특수 position입니다. `components` 필드 대신 `props` 필드를 사용합니다.
|
||
|
||
#### 병합 전략
|
||
|
||
| 전략 | 설명 | 예시 |
|
||
|------|------|------|
|
||
| `_append` | 배열 끝에 추가 | `tabs._append: [newTab]` → 기존 tabs 뒤에 추가 |
|
||
| `_prepend` | 배열 앞에 추가 | `tabs._prepend: [newTab]` → 기존 tabs 앞에 추가 |
|
||
| `_merge` | 객체 병합 (shallow) | `style._merge: {color: 'red'}` → 기존 style에 병합 |
|
||
| (직접 값) | 스칼라 덮어쓰기 | `disabled: true` → 기존 값 대체 |
|
||
|
||
#### inject_props 예시: 탭에 항목 추가
|
||
|
||
```json
|
||
{
|
||
"target_layout": "admin_user_detail",
|
||
"injections": [
|
||
{
|
||
"target_id": "user_detail_tabs",
|
||
"position": "inject_props",
|
||
"props": {
|
||
"tabs": {
|
||
"_append": [
|
||
{
|
||
"id": "ext_verification",
|
||
"label": "$t:admin.users.form.sections.verification_info",
|
||
"iconName": "shield"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"target_id": "extension_tab_content",
|
||
"position": "append_child",
|
||
"components": [
|
||
{
|
||
"id": "ext_verification_content",
|
||
"type": "basic",
|
||
"name": "Div",
|
||
"if": "{{(_global.activeUserDetailTab || query.tab || 'basic') === 'ext_verification'}}",
|
||
"children": [...]
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"priority": 100
|
||
}
|
||
```
|
||
|
||
#### 주의사항
|
||
|
||
- `inject_props`일 때 `components` 필드는 무시됩니다
|
||
- 대상 prop이 표현식 문자열인 경우 `_append`/`_prepend`/`_merge` 적용이 불가하며, 경고 로그가 기록됩니다
|
||
- `_append`와 `_merge` 등 여러 전략을 하나의 prop에 혼합 사용할 수 없습니다 (첫 번째 매칭 전략 우선)
|
||
|
||
### 예시: 사용자 폼에 알림 설정 추가
|
||
|
||
```json
|
||
{
|
||
"target_layout": "admin_user_form",
|
||
"injections": [
|
||
{
|
||
"target_id": "section_marketing_consent",
|
||
"position": "append",
|
||
"components": [
|
||
{
|
||
"id": "section_notification_settings",
|
||
"type": "basic",
|
||
"name": "Div",
|
||
"props": {
|
||
"className": "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6"
|
||
},
|
||
"children": [
|
||
{
|
||
"type": "basic",
|
||
"name": "Div",
|
||
"props": {
|
||
"className": "flex items-center justify-between mb-4"
|
||
},
|
||
"children": [
|
||
{
|
||
"type": "basic",
|
||
"name": "H3",
|
||
"props": {
|
||
"className": "text-lg font-semibold text-gray-900 dark:text-white"
|
||
},
|
||
"text": "$t:sirsoft-board.admin.users.form.sections.notification_settings"
|
||
},
|
||
{
|
||
"type": "composite",
|
||
"name": "ExtensionBadge",
|
||
"props": {
|
||
"type": "module",
|
||
"identifier": "sirsoft-board",
|
||
"installedModules": "{{_global.installedModules}}"
|
||
}
|
||
}
|
||
]
|
||
},
|
||
{
|
||
"type": "basic",
|
||
"name": "Input",
|
||
"props": {
|
||
"type": "checkbox",
|
||
"name": "notify_post_complete",
|
||
"checked": "{{_local.form?.notify_post_complete ?? false}}"
|
||
}
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"priority": 100
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Extension Point 확장
|
||
|
||
레이아웃에 미리 정의된 확장 포인트에 컴포넌트를 주입합니다.
|
||
|
||
### 지원 위치
|
||
|
||
Extension Point는 레이아웃의 다음 섹션에서 사용할 수 있습니다:
|
||
|
||
| 위치 | 지원 | 설명 |
|
||
|----------------|------|--------------------------------------|
|
||
| `components` | ✅ | 메인 컴포넌트 트리 (기본) |
|
||
| `modals` | ✅ | 모달 내부 컴포넌트 트리 (v1.17.0+) |
|
||
|
||
```text
|
||
v1.17.0 이전: modals 내부의 extension_point는 처리되지 않았음
|
||
✅ v1.17.0+: components와 modals 양쪽 모두 재귀적으로 extension_point 처리
|
||
```
|
||
|
||
### 레이아웃에서 Extension Point 정의
|
||
|
||
```json
|
||
{
|
||
"layout_name": "admin_user_form",
|
||
"components": [
|
||
{
|
||
"id": "user_form_container",
|
||
"type": "basic",
|
||
"name": "Div",
|
||
"children": [
|
||
{
|
||
"type": "extension_point",
|
||
"name": "user_form_additional_fields",
|
||
"default": [],
|
||
"props": {
|
||
"readOnlyFields": ["zipcode", "address"]
|
||
},
|
||
"callbacks": {
|
||
"onAddressSelect": { "handler": "setState", "params": { "target": "local", "form.zipcode": "{{$event.zipcode}}" } }
|
||
}
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
#### Extension Point 데이터 전달 (engine-v1.28.0+)
|
||
|
||
| 필드 | 타입 | 설명 |
|
||
|------|------|------|
|
||
| `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
|
||
{
|
||
"extension_point": "user_form_additional_fields",
|
||
"mode": "replace",
|
||
"components": [
|
||
{
|
||
"id": "custom_field_section",
|
||
"type": "basic",
|
||
"name": "Div",
|
||
"children": [...]
|
||
}
|
||
],
|
||
"data_sources": [...],
|
||
"priority": 100
|
||
}
|
||
```
|
||
|
||
### JSON 스키마
|
||
|
||
| 필드 | 필수 | 타입 | 설명 |
|
||
|------|------|------|------|
|
||
| `extension_point` | ✅ | string | 확장 포인트 이름 |
|
||
| `mode` | ❌ | string | 주입 모드 (아래 참조, 기본값: `append`) |
|
||
| `components` | ❌ | array | 주입할 컴포넌트 배열 |
|
||
| `data_sources` | ❌ | array | 추가 데이터 소스 |
|
||
| `modals` | ❌ | array | 호스트 레이아웃에 병합할 모달 배열 |
|
||
| `scripts` | ❌ | array | 추가 스크립트 |
|
||
| `priority` | ❌ | number | 우선순위 (기본값: 100) |
|
||
|
||
### mode 옵션
|
||
|
||
| 값 | 설명 | 동작 |
|
||
| ----- | ------ | ------ |
|
||
| `append` | default **뒤에** 추가 (기본값) | `[default...] [NEW]` |
|
||
| `prepend` | default **앞에** 추가 | `[NEW] [default...]` |
|
||
| `replace` | default **완전 교체** | `[NEW]` (default 제거) |
|
||
|
||
### 확장 병합 칸 (주입 폼의 값을 템플릿 API 요청에 실어 보내기)
|
||
|
||
슬롯에 주입한 폼이 입력값을 서버로 보내려면, 그 값이 **템플릿이 소유한 apiCall 의 `params.body`** 에 도달해야 한다. 그런데 확장에는 body 에 키를 추가할 수단이 없다.
|
||
|
||
- 결제 버튼처럼 `id` 가 없는 노드는 overlay `target_id` 로 잡을 수 없다
|
||
- `inject_props` 는 컴포넌트 props 병합 전용이라 `actions[].params.body` 에 닿지 않는다 (`_merge` 는 shallow)
|
||
|
||
그래서 템플릿이 **확장 병합 칸**을 하나 열어 둔다. 규약은 이렇다.
|
||
|
||
**템플릿 쪽** — body 를 통짜 표현식으로 만들고 말미에 확장 칸을 spread 한다.
|
||
|
||
```json
|
||
{
|
||
"handler": "apiCall",
|
||
"target": "/api/modules/sirsoft-ecommerce/user/orders",
|
||
"params": {
|
||
"body": "{{ ({ temp_order_id: _local.tempOrderId, payment_method: _computed.selectedPaymentMethod, ...(_local.checkoutExtraPayload ?? {}) }) }}"
|
||
}
|
||
}
|
||
```
|
||
|
||
**확장 쪽** — 자기 필드를 그 칸에 setState 한다.
|
||
|
||
```json
|
||
{
|
||
"type": "change",
|
||
"handler": "setState",
|
||
"params": {
|
||
"target": "local",
|
||
"checkoutExtraPayload.cash_receipt_requested": "{{$event.target.checked}}"
|
||
}
|
||
}
|
||
```
|
||
|
||
지켜야 할 것:
|
||
|
||
- 확장은 **자기 도메인 키만** 쓴다. spread 가 뒤에 오므로 템플릿의 기존 키를 덮어쓸 수 있다
|
||
- 폼을 접거나 신청을 해제하면 칸을 `{}` 로 비운다. 남겨 두면 서버가 신청으로 오인한다
|
||
- 템플릿이 소유한 필드(예: 환불계좌)는 칸을 경유하지 않고 body 에 직접 기재한다
|
||
- body 를 통짜 표현식으로 전환할 때는 **전환 전후 산출값이 동일한지** 회귀 테스트로 고정한다 (`DataBindingEngine.evaluateExpression` 으로 실제 평가)
|
||
|
||
현재 열려 있는 칸:
|
||
|
||
| 칸 이름 | 위치 | 소비처 |
|
||
|---------|------|--------|
|
||
| `_local.checkoutExtraPayload` | `sirsoft-basic` 주문서 (`_checkout_summary.json`) | 주문 생성 `POST /user/orders` |
|
||
|
||
---
|
||
|
||
## 템플릿 오버라이드
|
||
|
||
템플릿은 모듈/플러그인의 확장을 오버라이드하여 커스터마이징할 수 있습니다.
|
||
|
||
### 디렉토리 구조
|
||
|
||
```
|
||
templates/_bundled/sirsoft-admin_basic/
|
||
└── extensions/
|
||
└── sirsoft-board/ ← 오버라이드 대상 모듈
|
||
└── user-notification-settings.json ← 오버라이드 파일
|
||
```
|
||
|
||
### 오버라이드 파일명 규칙
|
||
|
||
모듈의 원본 확장 파일명과 **동일한 이름**을 사용합니다.
|
||
|
||
```
|
||
모듈 원본: modules/_bundled/sirsoft-board/resources/extensions/user-notification-settings.json
|
||
템플릿 오버라이드: templates/_bundled/sirsoft-admin_basic/extensions/sirsoft-board/user-notification-settings.json
|
||
```
|
||
|
||
### 오버라이드 우선순위
|
||
|
||
```
|
||
1. 템플릿 오버라이드 (source_type = Template) ← 가장 높은 우선순위
|
||
2. 플러그인 확장 (source_type = Plugin)
|
||
3. 모듈 확장 (source_type = Module) ← 가장 낮은 우선순위
|
||
```
|
||
|
||
### 오버라이드 예시
|
||
|
||
모듈의 기본 확장을 템플릿에서 스타일 변경:
|
||
|
||
```json
|
||
{
|
||
"target_layout": "admin_user_form",
|
||
"injections": [
|
||
{
|
||
"target_id": "section_marketing_consent",
|
||
"position": "append",
|
||
"components": [
|
||
{
|
||
"id": "section_notification_settings",
|
||
"type": "basic",
|
||
"name": "Div",
|
||
"props": {
|
||
"className": "bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-lg p-6 mb-6"
|
||
},
|
||
"children": [...]
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"priority": 100
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 모듈 활성화/비활성화 동작
|
||
|
||
### 활성화 시
|
||
|
||
1. `resources/extensions/*.json` 파일 스캔
|
||
2. **대상 템플릿 적용 가능 여부 판별** (아래 "템플릿 대상 판정" 참조)
|
||
3. `template_layout_extensions` 테이블에 등록 (또는 기존 레코드 복원)
|
||
4. 레이아웃 캐시 무효화
|
||
|
||
### 템플릿 대상 판정
|
||
|
||
모듈/플러그인 확장은 admin·user 등 모든 활성 템플릿에 동기화되지만, 확장이 **대상으로 하는 레이아웃이 실제 존재하는 템플릿에만 등록**된다.
|
||
|
||
- **Overlay**: `target_layout` 이 그 템플릿에 레이아웃으로 존재해야 한다.
|
||
- **Extension Point**: `extension_point`(확장점명)이 그 템플릿의 어느 레이아웃 `content` 에 `type: extension_point` 노드로 정의되어 있어야 한다.
|
||
|
||
판정은 `RefreshesLayoutExtensions` trait 이 `LayoutExtensionService::isExtensionApplicableToTemplate()` 로 수행한다. 대상이 없는 템플릿(예: admin 레이아웃 대상 확장 ↔ user 템플릿)에는 등록하지 않으며, 과거 무차별 등록으로 잘못 생성된 행은 다음 동기화 시 `removeInapplicableExtension()` 으로 정리(soft delete)된다.
|
||
|
||
### 비활성화 시
|
||
|
||
1. 해당 모듈의 확장 레코드 Soft Delete
|
||
2. **렌더링 시 비활성화된 모듈의 확장 자동 스킵**
|
||
3. 레이아웃 캐시 무효화
|
||
|
||
### 템플릿 오버라이드와 모듈 비활성화
|
||
|
||
**중요**: 모듈이 비활성화되면, 해당 모듈을 오버라이드하는 템플릿 확장도 함께 숨김 처리됩니다.
|
||
|
||
```
|
||
sirsoft-board 모듈 비활성화
|
||
↓
|
||
sirsoft-board의 모든 확장 숨김
|
||
↓
|
||
sirsoft-board를 오버라이드하는 템플릿 확장도 숨김
|
||
```
|
||
|
||
---
|
||
|
||
## 우선순위
|
||
|
||
### priority 필드
|
||
|
||
- 기본값: 100
|
||
- 낮을수록 먼저 렌더링
|
||
- 같은 위치에 여러 확장이 있을 경우 순서 결정
|
||
|
||
```json
|
||
{
|
||
"target_layout": "admin_user_form",
|
||
"priority": 50,
|
||
"injections": [...]
|
||
}
|
||
```
|
||
|
||
### 우선순위 가이드라인
|
||
|
||
| 범위 | 용도 | 예시 |
|
||
|------|------|------|
|
||
| 1-25 | 필수 정보 | 보안 경고, 중요 알림 |
|
||
| 50-75 | 주요 기능 | 핵심 모듈 UI |
|
||
| 100 | 기본값 | 일반 확장 |
|
||
| 150+ | 보조 정보 | 분석, 통계 |
|
||
|
||
---
|
||
|
||
## 데이터 소스 병합
|
||
|
||
확장에서 정의한 `data_sources`는 호스트 레이아웃의 데이터 소스에 병합됩니다.
|
||
|
||
```json
|
||
{
|
||
"target_layout": "admin_user_form",
|
||
"injections": [...],
|
||
"data_sources": [
|
||
{
|
||
"id": "notification_settings",
|
||
"type": "api",
|
||
"method": "GET",
|
||
"endpoint": "/api/modules/sirsoft-board/admin/users/{{route.id}}/notification-settings",
|
||
"auto_fetch": true,
|
||
"auth_required": true
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 모달 병합
|
||
|
||
Extension Point 확장에서 정의한 `modals`는 호스트 레이아웃의 `modals` 배열에 자동 병합됩니다.
|
||
|
||
### 왜 modals 섹션을 사용해야 하는가?
|
||
|
||
Extension Point의 children으로 주입된 인라인 Modal(`show` prop 기반)은 정상 동작하지 않습니다. 그누보드7 Modal 시스템에서 안정적으로 동작하려면 `modals` 섹션에 등록하고 `openModal`/`closeModal` 핸들러로 제어해야 합니다.
|
||
|
||
> 상세 모달 규칙: [modal-usage.md](../frontend/modal-usage.md)
|
||
|
||
### Extension JSON에서 modals 정의
|
||
|
||
```json
|
||
{
|
||
"extension_point": "shop_checkout_extensions",
|
||
"modals": [
|
||
{
|
||
"id": "payment_error_modal",
|
||
"type": "composite",
|
||
"name": "Modal",
|
||
"props": {
|
||
"title": "$t:plugin.payment_error_title",
|
||
"size": "small"
|
||
},
|
||
"children": [...]
|
||
}
|
||
],
|
||
"priority": 100
|
||
}
|
||
```
|
||
|
||
### 핸들러에서 모달 열기
|
||
|
||
Extension으로 병합된 모달은 커스텀 핸들러에서 `G7Core.modal.open()`으로 열 수 있습니다:
|
||
|
||
```typescript
|
||
const G7Core = (window as any).G7Core;
|
||
G7Core?.modal?.open?.('payment_error_modal');
|
||
```
|
||
|
||
또는 레이아웃 JSON 액션에서:
|
||
|
||
```json
|
||
{
|
||
"type": "click",
|
||
"handler": "openModal",
|
||
"target": "payment_error_modal"
|
||
}
|
||
```
|
||
|
||
### 병합 동작
|
||
|
||
- 여러 확장의 `modals`가 하나의 호스트 레이아웃에 모두 병합됩니다
|
||
- 호스트 레이아웃에 기존 `modals`가 있으면 확장 모달이 뒤에 추가됩니다
|
||
- 확장에 `modals`가 없으면 호스트 레이아웃에 영향 없음 (하위 호환)
|
||
|
||
### 주의사항
|
||
|
||
```text
|
||
modals 섹션 모달에 show prop 사용 금지 (무시됨)
|
||
모달 ID는 호스트 레이아웃의 기존 모달과 중복되지 않도록 플러그인 prefix 사용 권장
|
||
✅ 모달 닫기 시 setState → closeModal 순서 유지 (순서 중요)
|
||
```
|
||
|
||
---
|
||
|
||
## Overlay 레이아웃 섹션 병합
|
||
|
||
Overlay 확장에서 `computed`, `state`, `modals` 섹션을 정의하면 호스트 레이아웃에 자동 병합됩니다.
|
||
|
||
### 지원 섹션
|
||
|
||
| 섹션 | Extension Point | Overlay | 설명 |
|
||
|------|:---:|:---:|------|
|
||
| `data_sources` | ✅ | ✅ | API 데이터 소스 추가 |
|
||
| `scripts` | ✅ | ✅ | 커스텀 핸들러 스크립트 추가 |
|
||
| `modals` | ✅ | ✅ | 모달 컴포넌트 추가 |
|
||
| `computed` | ✅ | ✅ | 계산된 값 추가 |
|
||
| `state` | ✅ | ✅ | 초기 상태 추가 |
|
||
|
||
### 예시: Overlay에서 computed와 state 추가
|
||
|
||
```json
|
||
{
|
||
"target_layout": "admin_user_detail",
|
||
"injections": [...],
|
||
"computed": {
|
||
"verificationStatus": "{{user?.data?.identity_verified ? 'verified' : 'pending'}}"
|
||
},
|
||
"state": {
|
||
"showVerificationHistory": false
|
||
},
|
||
"modals": [
|
||
{
|
||
"id": "ext_verification_modal",
|
||
"type": "composite",
|
||
"name": "Modal",
|
||
"props": { "title": "인증 이력" },
|
||
"children": [...]
|
||
}
|
||
],
|
||
"priority": 100
|
||
}
|
||
```
|
||
|
||
### 병합 동작
|
||
|
||
- 여러 overlay의 같은 섹션은 priority 오름차순으로 순차 병합됩니다
|
||
- 동일 키 충돌 시 후순위 overlay가 덮어씁니다
|
||
- 해당 섹션이 없는 overlay는 기존 레이아웃에 영향 없음 (하위 호환)
|
||
|
||
---
|
||
|
||
## 레이아웃 오버라이드 vs 확장 오버라이드
|
||
|
||
| 구분 | 레이아웃 오버라이드 | 확장 오버라이드 |
|
||
|------|-------------------|---------------|
|
||
| 대상 | 모듈이 등록한 완전한 레이아웃 | 확장 지점/Overlay로 주입된 UI |
|
||
| 위치 | `templates/{t}/layouts/overrides/{module}/*.json` | `templates/{t}/extensions/{module}/*.json` |
|
||
| DB 테이블 | `template_layouts` | `template_layout_extensions` |
|
||
| 해석 서비스 | `LayoutResolverService` | `LayoutExtensionService` |
|
||
| version_constraint | 지원 | 지원 |
|
||
|
||
---
|
||
|
||
## 레이아웃 확장 편집 및 버전 관리
|
||
|
||
관리자는 모듈/플러그인이 주입한 레이아웃 확장을 일반 레이아웃과 동등한 수준으로 편집·버전관리·복구·미리보기할 수 있다.
|
||
|
||
### 관리자 편집
|
||
|
||
레이아웃 편집 화면(`admin_template_layout_edit`)의 좌측 트리에서 확장 항목을 선택하면 해당 확장의 `content` JSON 을 편집할 수 있다. 편집 권한은 일반 레이아웃과 동일한 `core.templates.layouts.edit` 를 사용한다.
|
||
|
||
확장 편집 API 는 `{extensionId}` 정수 PK 로 식별한다 — 동일 `target_name` 에 여러 source 의 확장이 존재할 수 있기 때문이다.
|
||
|
||
#### 좌측 트리는 오버라이드 해석 결과만 노출한다
|
||
|
||
편집 화면 좌측 트리(`GET .../layout-extensions`)는 화면 렌더링 경로와 동일한 오버라이드 해석을 적용한다. 동일 `(extension_type, target_name)` 범위에서 템플릿 오버라이드(`source_type = Template`)가 특정 모듈/플러그인을 오버라이드하면, 그 모듈/플러그인의 원본 확장은 화면에 적용되지 않으므로 트리에서도 제외된다.
|
||
|
||
이는 관리자가 "저장은 되지만 화면에 반영되지 않는" 가려진 확장 행을 편집하는 것을 막기 위함이다. 트리에는 항상 해당 템플릿에서 실제로 적용되는 확장(템플릿 오버라이드 + 오버라이드되지 않은 모듈/플러그인 확장)만 표시된다.
|
||
|
||
해석 로직은 `LayoutExtensionRepository::resolveOverrides()` 단일 출처를 사용하며, 렌더링 경로(`getResolvedExtensionPoints` / `getResolvedOverlays`)와 편집 트리(`getResolvedByTemplateId`)가 동일한 가시성 기준을 공유한다.
|
||
|
||
| 메서드 | 라우트 |
|
||
| ------ | ------ |
|
||
| `GET` | `/api/admin/templates/{templateName}/layout-extensions` (출처별 그룹핑) |
|
||
| `GET` | `.../layout-extensions/{extensionId}` |
|
||
| `PUT` | `.../layout-extensions/{extensionId}` |
|
||
| `GET` | `.../layout-extensions/{extensionId}/versions` |
|
||
| `GET` | `.../layout-extensions/{extensionId}/versions/{version}` |
|
||
| `POST` | `.../layout-extensions/{extensionId}/versions/{versionId}/restore` |
|
||
| `POST` | `.../layout-extensions/{extensionId}/preview` |
|
||
|
||
### 버전 이력
|
||
|
||
확장을 편집·저장하면 `template_layout_extension_versions` 테이블에 버전 스냅샷이 쌓인다. 일반 레이아웃의 `template_layout_versions` 와 동일한 구조이며, 이전 버전으로 복구할 수 있다. 버전 diff 계산 로직은 `App\Repositories\Concerns\CalculatesJsonContentDiff` trait 으로 일반 레이아웃과 공유한다.
|
||
|
||
### 사용자 수정 감지 (`original_content_hash`)
|
||
|
||
`template_layout_extensions` 의 `original_content_hash` / `original_content_size` 컬럼은 모듈/플러그인이 등록한 원본 확장 콘텐츠의 정규화 해시를 보존한다. 관리자가 확장을 편집해도 이 원본 해시는 변경되지 않는다 — 현재 `content` 해시와 원본 해시를 비교하여 "사용자가 수정한 확장"을 판별한다.
|
||
|
||
### 모듈/플러그인 업데이트 시 충돌 전략
|
||
|
||
`module:update` / `plugin:update` 의 `--layout-strategy` 옵션이 확장에도 적용된다 (일반 레이아웃과 동일한 수준):
|
||
|
||
- `overwrite` (기본): 파일 내용으로 확장 `content` 를 무조건 덮어쓴다. 관리자가 편집한 확장이 있으면 업데이트 전 경고를 출력한다.
|
||
- `keep`: 관리자가 편집한 확장(`original_content_hash` 불일치)은 `content`/`priority` 갱신을 건너뛰고 `is_active` 만 갱신한다. 사용자 수정분이 보존된다.
|
||
|
||
### 미리보기
|
||
|
||
확장 편집 중 미리보기는 편집 중인 `content` 를 대표 레이아웃에 임시 적용하여 렌더링한다.
|
||
|
||
- `overlay` 타입: `target_layout` 자체가 대표 레이아웃이므로 별도 선택이 불필요하다.
|
||
- `extension_point` 타입: 하나의 확장점이 여러 레이아웃에서 사용될 수 있으므로, 미리볼 대표 레이아웃을 선택하는 모달이 노출된다.
|
||
|
||
미리보기 URL 은 일반 레이아웃과 동일한 `/api/layouts/preview/{token}.json` 을 재사용하며, `preview_type` 컬럼으로 일반/확장 미리보기를 분기한다.
|
||
|
||
---
|
||
|
||
## 관련 파일
|
||
|
||
### 백엔드
|
||
|
||
| 파일 | 설명 |
|
||
|------|------|
|
||
| `app/Services/LayoutExtensionService.php` | 확장 적용·편집·버전관리 핵심 서비스 |
|
||
| `app/Models/LayoutExtension.php` | 확장 모델 (DB 테이블) |
|
||
| `app/Models/TemplateLayoutExtensionVersion.php` | 확장 버전 이력 모델 |
|
||
| `app/Repositories/LayoutExtensionRepository.php` | 확장 Repository |
|
||
| `app/Repositories/LayoutExtensionVersionRepository.php` | 확장 버전 Repository |
|
||
| `app/Repositories/Concerns/CalculatesJsonContentDiff.php` | JSON diff 계산 trait (일반 레이아웃과 공유) |
|
||
| `app/Http/Controllers/Api/Admin/LayoutExtensionController.php` | 확장 편집/버전관리 admin 컨트롤러 |
|
||
| `app/Enums/LayoutExtensionType.php` | 확장 타입 Enum (ExtensionPoint, Overlay) |
|
||
| `app/Enums/LayoutSourceType.php` | 출처 타입 Enum (Module, Plugin, Template) |
|
||
|
||
### 데이터베이스
|
||
|
||
| 테이블 | 설명 |
|
||
|--------|------|
|
||
| `template_layout_extensions` | 확장 등록 정보 저장 |
|
||
|
||
### 주요 컬럼
|
||
|
||
```
|
||
template_layout_extensions
|
||
├── template_id # 템플릿 ID
|
||
├── extension_type # ExtensionPoint | Overlay
|
||
├── target_name # 확장 포인트명 또는 대상 레이아웃명
|
||
├── source_type # Module | Plugin | Template
|
||
├── source_identifier # 모듈/플러그인/템플릿 식별자
|
||
├── override_target # 오버라이드 대상 (템플릿 오버라이드 시)
|
||
├── content # 확장 JSON 내용
|
||
├── original_content_hash # 원본 확장 콘텐츠 SHA-256 해시 (사용자 수정 감지용)
|
||
├── original_content_size # 원본 확장 콘텐츠 바이트 크기
|
||
├── priority # 우선순위
|
||
├── is_active # 활성 상태
|
||
└── deleted_at # Soft Delete
|
||
|
||
template_layout_extension_versions
|
||
├── extension_id # 레이아웃 확장 ID (FK, cascadeOnDelete)
|
||
├── version # 버전 번호 (자동 증가)
|
||
├── content # 확장 정의 JSON 스냅샷
|
||
├── changes_summary # 변경 요약 JSON (added/removed/modified/char_diff)
|
||
└── created_by # 저장자 ID
|
||
```
|
||
|
||
---
|
||
|
||
## 문제 해결
|
||
|
||
### 확장이 표시되지 않음
|
||
|
||
**확인 사항**:
|
||
1. 모듈/플러그인이 활성화되어 있는지 확인
|
||
2. `target_layout` 또는 `extension_point` 이름이 정확한지 확인
|
||
3. `target_id`가 대상 레이아웃에 존재하는지 확인
|
||
4. JSON 문법 오류 확인
|
||
|
||
**캐시 초기화**:
|
||
```bash
|
||
php artisan cache:clear
|
||
```
|
||
|
||
### 오버라이드가 적용되지 않음
|
||
|
||
**확인 사항**:
|
||
1. 템플릿 오버라이드 파일 경로 확인
|
||
- `templates/{template}/extensions/{module-identifier}/{filename}.json`
|
||
2. 파일명이 원본과 동일한지 확인
|
||
3. `template_layout_extensions` 테이블에서 `source_type = 'template'` 레코드 확인
|
||
|
||
### 모듈 비활성화 후에도 확장 표시됨
|
||
|
||
**원인**: 캐시 문제
|
||
|
||
**해결**:
|
||
```bash
|
||
php artisan cache:clear
|
||
```
|
||
|
||
### 여러 확장의 순서가 예상과 다름
|
||
|
||
**해결**: `priority` 값 조정 (낮을수록 먼저 렌더링)
|
||
|
||
---
|
||
|
||
## 관련 문서
|
||
|
||
- [module-layouts.md](module-layouts.md) - 모듈 레이아웃 등록
|
||
- [hooks.md](hooks.md) - 훅 시스템
|
||
- [layout-json.md](../frontend/layout-json.md) - 레이아웃 JSON 스키마
|
||
- [components.md](../frontend/components.md) - 컴포넌트 개발 규칙
|