일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃을 각각 렌더하는데 봇 화면 쪽 지원 범위가 뒤처져, 확장이 교체한 본문이 통째로 빈 채 나갔다 (공개 이슈 ). 같은 결손 계열을 전수 조사해 함께 닫는다. - 확장 주입 props 를 렌더 진입 시점에 해석해 자손 전체에 상속 → 게시글·답글 원글·댓글·페이지·상품 설명 5개 지점 복구 - 사용자 작성 HTML 정화기 신설 — isHtml 판정에 따라 이스케이프/정화. 미정화 상태에서 봇 경로로 저장형 스크립트가 실제 실행됐다 - 노드 키 패리티: conditions / type:iterator / responsive 매칭 규칙 / children 문자열 / text 우선순위 / $t:defer / 레이아웃 초기 상태 - 표현식: 단일 바인딩 판정 교체, ||·&& 를 값 반환(JS 시맨틱)으로, 비교식이 raw 경로에서 null 이 되던 문제 - 데이터소스: 엔드포인트 표현식 전면 해석 + 레이아웃 8곳 선언 보강 - 봇 페이지 캐시 1회 전량 무효화 업그레이드 스텝 지원 범위 SSoT 표를 문서에 고정하고, 한쪽만 고치는 것을 막는 검사 룰과 레이아웃 전수 순회 테스트로 잠갔다.
948 lines
30 KiB
Markdown
948 lines
30 KiB
Markdown
# 레이아웃 JSON 스키마
|
|
|
|
> **관련 문서**: [컴포넌트 개발](components.md) | [데이터 바인딩](data-binding.md) | [데이터 소스](data-sources.md)
|
|
|
|
---
|
|
|
|
## TL;DR (5초 요약)
|
|
|
|
```text
|
|
1. HTML 태그 직접 사용 금지 → 기본 컴포넌트 사용 (Div, Button, Span)
|
|
2. 텍스트: text 속성 또는 children 내 Span 사용 (텍스트 직접 배치 금지)
|
|
3. 다국어: "$t:key" 형식, 파라미터: "$t:key|param={{value}}"
|
|
4. 데이터 바인딩: "{{path.to.data}}" 형식
|
|
5. 다크 모드: light/dark 클래스 쌍 필수 (bg-white dark:bg-gray-800)
|
|
6. 새 속성 추가 시: UpdateLayoutContentRequest rules에도 추가 필수
|
|
7. 권한 제어: permissions 필드로 접근 권한 설정 (AND 로직, 401/403 응답)
|
|
8. globalHeaders: 패턴 기반 공통 헤더 자동 주입 (engine-v1.16.0+)
|
|
```
|
|
|
|
---
|
|
|
|
## 분리된 문서
|
|
|
|
이 문서는 가독성을 위해 다음과 같이 분리되었습니다:dff
|
|
|
|
| 문서 | 내용 |
|
|
|------|------|
|
|
| **layout-json.md** (현재) | 개요, 필수 필드, 컴포넌트 정의, 텍스트 렌더링 |
|
|
| [layout-json-features.md](layout-json-features.md) | 에러 핸들링, 초기화 액션, 모달 시스템, 액션 시스템 |
|
|
| [layout-json-components.md](layout-json-components.md) | 반복 렌더링(iteration), Blur 효과, 컴포넌트 생명주기 |
|
|
| [layout-json-inheritance.md](layout-json-inheritance.md) | 레이아웃 상속, Partial, 병합 구조, 상속 체인 검증 |
|
|
|
|
---
|
|
|
|
## 목차
|
|
|
|
1. [개요](#개요)
|
|
2. [필수 필드](#필수-필드)
|
|
3. [레이아웃 권한 (permissions)](#레이아웃-권한-permissions)
|
|
4. [컴포넌트 정의](#컴포넌트-정의)
|
|
5. [격리된 상태 (isolatedState)](#격리된-상태-isolatedstate)
|
|
6. [텍스트 렌더링](#텍스트-렌더링)
|
|
|
|
---
|
|
|
|
## 개요
|
|
|
|
**레이아웃 JSON**은 화면 구성을 정의한 JSON 파일로, 데이터베이스 `template_layouts` 테이블에 저장됩니다.
|
|
|
|
그누보드7 템플릿 시스템에서 레이아웃 JSON은 다음 역할을 합니다:
|
|
- 화면에 표시될 컴포넌트 구조 정의
|
|
- API 데이터 소스 연결
|
|
- 사용자 인터랙션(액션) 처리
|
|
- 다국어 및 데이터 바인딩 지원
|
|
|
|
---
|
|
|
|
## 필수 필드
|
|
|
|
```json
|
|
{
|
|
"version": "1.0.0",
|
|
"layout_name": "dashboard",
|
|
"meta": {
|
|
"title": "$t:dashboard.title",
|
|
"description": "$t:dashboard.description"
|
|
},
|
|
"data_sources": [],
|
|
"components": []
|
|
}
|
|
```
|
|
|
|
**필드 설명**:
|
|
|
|
| 필드 | 타입 | 필수 | 설명 |
|
|
|------|------|------|------|
|
|
| `version` | string | ✅ | 스키마 버전 (예: "1.0.0") |
|
|
| `layout_name` | string | ✅ | 레이아웃 식별자 (아래 네이밍 규칙 참조) |
|
|
| `meta` | object | ❌ | 메타 정보 (title, description) |
|
|
| `data_sources` | array | ✅ | API 데이터 소스 정의 (각 항목에 `label_key` `$t:` 키 — 편집기 데이터 연결 피커 친화 명칭) |
|
|
| `init_actions` | array | ❌ | 초기화 액션 (레이아웃 로드 시 실행) |
|
|
| `modals` | array | ❌ | 모달 컴포넌트 정의 |
|
|
| `scripts` | array | ❌ | 외부 스크립트 동적 로드 (engine-v1.8.0+) |
|
|
| `errorHandling` | object | ❌ | 레이아웃 레벨 에러 핸들링 설정 (engine-v1.6.0+) |
|
|
| `permissions` | string[] | ❌ | 레이아웃 접근 권한 식별자 배열 (engine-v1.15.0+) |
|
|
| `globalHeaders` | array | ❌ | 전역 HTTP 헤더 규칙 배열 (engine-v1.16.0+) |
|
|
| `meta.seo` | object | ❌ | SEO 페이지 생성기 설정 (아래 참조) |
|
|
| `components` | array | ✅ | 컴포넌트 배열 |
|
|
|
|
### layout_name 네이밍 규칙
|
|
|
|
```text
|
|
✅ 허용 문자: 영문(a-z, A-Z), 숫자(0-9), 언더스코어(_), 슬래시(/), 하이픈(-), 점(.)
|
|
✅ 슬래시 사용: 계층 구조 표현 가능 (예: "board/popular", "mypage/orders/show")
|
|
✅ 언더스코어 사용: 플랫 구조 표현 가능 (예: "admin_dashboard", "admin_user_list")
|
|
파일 경로와 일치: layout_name은 파일 경로(확장자 제외)와 동일해야 함
|
|
```
|
|
|
|
**예시**:
|
|
|
|
| 파일 경로 | layout_name |
|
|
|----------|-------------|
|
|
| `layouts/home.json` | `home` |
|
|
| `layouts/board/popular.json` | `board/popular` |
|
|
| `layouts/mypage/orders/show.json` | `mypage/orders/show` |
|
|
| `layouts/admin_dashboard.json` | `admin_dashboard` |
|
|
|
|
> **상세 문서**:
|
|
> - `errorHandling`: [layout-json-features.md](layout-json-features.md#에러-핸들링-설정-errorhandling)
|
|
> - `init_actions`: [layout-json-features.md](layout-json-features.md#초기화-액션-init_actions)
|
|
> - `modals`: [layout-json-features.md](layout-json-features.md#모달-시스템-modals)
|
|
> - `scripts`: [layout-json-features.md](layout-json-features.md#외부-스크립트-로드-scripts)
|
|
> - `permissions`: [아래 섹션](#레이아웃-권한-permissions)
|
|
> - `globalHeaders`: [아래 섹션](#전역-헤더-globalheaders)
|
|
> - `meta.seo`: [아래 섹션](#metaseo-seo-페이지-생성기)
|
|
|
|
### meta.seo (SEO 페이지 생성기)
|
|
|
|
| 필드 | 타입 | 필수 | 설명 |
|
|
|------|------|------|------|
|
|
| seo.enabled | boolean | X | SEO 렌더링 활성화 (기본: false) |
|
|
| seo.data_sources | string[] | X | SEO 시 사전 로드할 data_sources ID |
|
|
| seo.extensions | array | X | SEO 변수 제공 확장 목록 (아래 참조) |
|
|
| seo.page_type | string | X | 확장 설정 SEO 템플릿 키 결정 (예: `"product"`) |
|
|
| seo.toggle_setting | string | X | SEO 활성화 토글 설정 경로 |
|
|
| seo.vars | object | X | SEO 변수 선언 — data 소스 변수의 표현식 매핑 |
|
|
| seo.priority | number | X | sitemap priority (0.0~1.0) |
|
|
| seo.changefreq | string | X | sitemap changefreq (daily/weekly 등) |
|
|
| seo.og | object | X | Open Graph 메타태그 |
|
|
| seo.structured_data | object | X | JSON-LD 구조화 데이터 |
|
|
|
|
#### seo.extensions
|
|
|
|
SEO 변수를 제공하는 확장(모듈/플러그인)을 선언합니다. 여기에 선언된 확장의 `seoVariables()` 메서드가 호출되어, `page_type`에 해당하는 변수가 자동 해석됩니다.
|
|
|
|
```json
|
|
"extensions": [
|
|
{ "type": "module", "id": "sirsoft-ecommerce" },
|
|
{ "type": "plugin", "id": "sirsoft-payment" }
|
|
]
|
|
```
|
|
|
|
| 필드 | 타입 | 필수 | 설명 |
|
|
|------|------|------|------|
|
|
| type | `"module"` \| `"plugin"` | ✅ | 확장 타입 |
|
|
| id | string | ✅ | 확장 식별자 (예: `"sirsoft-ecommerce"`) |
|
|
|
|
이 방식은 레이아웃 이름에 dot-notation `moduleIdentifier`를 포함할 필요 없이, 레이아웃이 어떤 확장의 SEO 변수를 사용하는지 명시적으로 선언합니다.
|
|
|
|
Partial에 meta.seo 정의 금지 (무시됨)
|
|
meta.seo 추가/변경 시 UpdateLayoutContentRequest 검증 규칙 동기화 필수
|
|
data_sources는 부모-자식 간 합집합 병합 (permissions와 동일 전략)
|
|
|
|
> 상세: [seo-system.md](../backend/seo-system.md)
|
|
|
|
---
|
|
|
|
## 레이아웃 권한 (permissions)
|
|
|
|
> **버전**: engine-v1.15.0+
|
|
|
|
레이아웃 접근에 필요한 권한을 정의합니다. 권한이 없는 사용자는 레이아웃 서빙 API에서 401/403 응답을 받습니다.
|
|
|
|
### 기본 문법
|
|
|
|
```json
|
|
{
|
|
"version": "1.0.0",
|
|
"layout_name": "admin_dashboard",
|
|
"permissions": ["core.dashboard.read"],
|
|
"meta": {
|
|
"title": "$t:admin.dashboard.title"
|
|
},
|
|
"components": [...]
|
|
}
|
|
```
|
|
|
|
### 동작 규칙
|
|
|
|
| 조건 | 동작 |
|
|
|------|------|
|
|
| `permissions` 필드 없음 또는 빈 배열 | 공개 레이아웃 (권한 체크 없음) |
|
|
| `permissions`에 값이 있음 | 모든 권한 필요 (AND 로직) |
|
|
| 비회원 + 권한 없음 | 401 Unauthorized |
|
|
| 회원 + 권한 없음 | 403 Forbidden |
|
|
| Admin 역할 | 모든 권한 자동 통과 |
|
|
|
|
### 권한 식별자 형식
|
|
|
|
```text
|
|
✅ 권한 식별자 규칙: [모듈명].[엔티티].[액션]
|
|
✅ 허용 문자: 영문(a-z), 숫자(0-9), 하이픈(-), 언더스코어(_), 점(.)
|
|
✅ 예시: core.dashboard.read, sirsoft-ecommerce.products.read
|
|
```
|
|
|
|
### 상속 시 권한 병합
|
|
|
|
부모-자식 레이아웃 상속 시 permissions는 **합집합**으로 병합됩니다:
|
|
|
|
```text
|
|
부모: { "permissions": ["core.admin.access"] }
|
|
자식: { "permissions": ["core.users.read"] }
|
|
결과: { "permissions": ["core.admin.access", "core.users.read"] }
|
|
```
|
|
|
|
- 중복 제거 적용 (`array_unique`)
|
|
- 병합 결과의 모든 권한을 만족해야 접근 가능 (AND 로직)
|
|
|
|
### 예시
|
|
|
|
#### 관리자 레이아웃
|
|
|
|
```json
|
|
{
|
|
"version": "1.0.0",
|
|
"layout_name": "admin_user_list",
|
|
"permissions": ["core.users.read"],
|
|
"meta": { "title": "$t:admin.users.title" },
|
|
"components": [...]
|
|
}
|
|
```
|
|
|
|
#### 모듈 관리 레이아웃
|
|
|
|
```json
|
|
{
|
|
"version": "1.0.0",
|
|
"layout_name": "admin_ecommerce_product_list",
|
|
"permissions": ["sirsoft-ecommerce.products.read"],
|
|
"meta": { "title": "$t:admin.ecommerce.products.title" },
|
|
"components": [...]
|
|
}
|
|
```
|
|
|
|
#### 공개 레이아웃 (권한 없음)
|
|
|
|
```json
|
|
{
|
|
"version": "1.0.0",
|
|
"layout_name": "home",
|
|
"meta": { "title": "$t:common.home" },
|
|
"components": [...]
|
|
}
|
|
```
|
|
|
|
### 일괄 업데이트 커맨드
|
|
|
|
기존 레이아웃에 권한을 일괄 추가하려면 Artisan 커맨드를 사용합니다:
|
|
|
|
```bash
|
|
# 시뮬레이션 (실제 수정 없음)
|
|
php artisan layout:add-permissions --dry-run
|
|
|
|
# 실제 실행
|
|
php artisan layout:add-permissions
|
|
|
|
# 특정 레이아웃만
|
|
php artisan layout:add-permissions --layout=admin_dashboard
|
|
```
|
|
|
|
### 주의사항
|
|
|
|
```text
|
|
권한 식별자는 DB의 permissions 테이블에 존재해야 함
|
|
빈 배열 []은 권한 체크 없음과 동일 (공개 레이아웃)
|
|
프론트엔드는 401/403 응답 시 적절한 UI 처리 필요 (로그인 페이지 리다이렉트 등)
|
|
```
|
|
|
|
---
|
|
|
|
## 컴포넌트별 권한 (Component Permissions)
|
|
|
|
> **버전**: engine-v1.17.0+
|
|
|
|
개별 컴포넌트에 `permissions` 속성을 추가하면, **서버 사이드에서 해당 컴포넌트를 JSON에서 제거**하여 서빙합니다. 프론트엔드 `if` 조건부 렌더링과 달리, 권한 없는 컴포넌트의 구조 자체가 클라이언트에 노출되지 않습니다.
|
|
|
|
### 기본 문법
|
|
|
|
```json
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"id": "admin_widget",
|
|
"permissions": ["core.dashboard.admin-widget"],
|
|
"children": [
|
|
{ "type": "basic", "name": "H2", "text": "관리자 전용 위젯" }
|
|
]
|
|
}
|
|
```
|
|
|
|
### 동작 규칙
|
|
|
|
| 조건 | 동작 |
|
|
|------|------|
|
|
| `permissions` 없음 또는 빈 배열 | 항상 포함 (권한 체크 없음) |
|
|
| `permissions`에 값이 있음 | 모든 권한 필요 (AND 로직) |
|
|
| 권한 없음 | 컴포넌트 + 하위 children 전체 제거 |
|
|
| Admin 역할 | 모든 컴포넌트 자동 통과 |
|
|
|
|
### 필터링 대상 영역
|
|
|
|
| 영역 | 설명 |
|
|
|------|------|
|
|
| `components` | 메인 컴포넌트 트리 (재귀 children 포함) |
|
|
| `modals[]` | 모달 자체에 permissions → 모달 전체 제거 |
|
|
| `modals[].components` | 모달 내부 컴포넌트 트리 |
|
|
| `defines` | 재사용 컴포넌트 정의 |
|
|
|
|
### 상위-하위 중복 선언
|
|
|
|
각 노드는 자기 permissions만 독립 평가합니다. 별도 병합 정책 없음:
|
|
|
|
```json
|
|
{
|
|
"type": "basic", "name": "Div",
|
|
"permissions": ["core.users.read"],
|
|
"children": [
|
|
{
|
|
"type": "basic", "name": "Button",
|
|
"permissions": ["core.users.delete"],
|
|
"text": "삭제"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
| 시나리오 | 상위 | 하위 | 결과 |
|
|
|---------|------|------|------|
|
|
| read 없음 | 전체 제거 | 평가 안 됨 | 둘 다 없음 |
|
|
| read 있음 + delete 없음 | 통과 | 제거 | Div만 남음 |
|
|
| read 있음 + delete 있음 | 통과 | 통과 | 둘 다 남음 |
|
|
|
|
### 레이아웃 최상위 permissions와의 차이
|
|
|
|
| 구분 | 레이아웃 최상위 | 컴포넌트별 |
|
|
|------|---------------|-----------|
|
|
| **범위** | 레이아웃 전체 접근 제어 | 개별 컴포넌트 표시/숨김 |
|
|
| **실패 시** | 401/403 에러 응답 | 해당 컴포넌트만 JSON에서 제거 |
|
|
| **캐싱** | 캐시 전 체크 | post-cache 필터링 (캐시 영향 없음) |
|
|
|
|
### 확장(모듈/플러그인) 컴포넌트
|
|
|
|
Extension Point 또는 Overlay로 주입된 컴포넌트에도 `permissions`를 선언할 수 있습니다. 필터링은 `applyExtensions()` 이후에 수행되므로 자동 처리됩니다.
|
|
|
|
### 주의사항
|
|
|
|
```text
|
|
권한 식별자는 DB의 permissions 테이블에 존재해야 함
|
|
보안 목적: 프론트엔드 if 조건과 달리 JSON에 컴포넌트 구조가 노출되지 않음
|
|
필터링 후 permissions 속성 자체도 제거됨 (클라이언트 노출 방지)
|
|
post-cache filtering — 기존 캐시 구조에 영향 없음
|
|
```
|
|
|
|
---
|
|
|
|
## 전역 헤더 (globalHeaders)
|
|
|
|
> **버전**: engine-v1.16.0+
|
|
|
|
레이아웃에서 정의한 모든 API 호출(data_sources, apiCall 핸들러)에 공통 헤더를 자동으로 주입합니다.
|
|
|
|
### 기본 문법
|
|
|
|
```json
|
|
{
|
|
"version": "1.0.0",
|
|
"layout_name": "shop_cart",
|
|
"globalHeaders": [
|
|
{
|
|
"pattern": "*",
|
|
"headers": { "X-Request-Source": "user-template" }
|
|
},
|
|
{
|
|
"pattern": "/api/modules/sirsoft-ecommerce/*",
|
|
"headers": { "X-Cart-Key": "{{_global.cartKey}}" }
|
|
}
|
|
],
|
|
"components": [...]
|
|
}
|
|
```
|
|
|
|
### globalHeaders 배열 구조
|
|
|
|
| 필드 | 타입 | 필수 | 설명 |
|
|
|------|------|------|------|
|
|
| `pattern` | string | ✅ | 엔드포인트 매칭 패턴 (`*`, `/api/shop/*` 등) |
|
|
| `headers` | object | ✅ | 헤더 키-값 쌍 (표현식 지원) |
|
|
|
|
### 패턴 매칭 규칙
|
|
|
|
| 패턴 | 설명 | 매칭 예시 |
|
|
|------|------|----------|
|
|
| `*` | 모든 API | 전체 적용 |
|
|
| `/api/modules/sirsoft-ecommerce/*` | ecommerce 모듈 API | `/api/modules/sirsoft-ecommerce/cart`, `/api/modules/sirsoft-ecommerce/products/123` |
|
|
| `/api/shop/*` | shop prefix API | `/api/shop/products`, `/api/shop/categories` |
|
|
| `/api/cart` | 정확히 일치 | `/api/cart`만 (하위 경로 제외) |
|
|
|
|
### 표현식 지원
|
|
|
|
헤더 값에서 데이터 바인딩 표현식을 사용할 수 있습니다:
|
|
|
|
```json
|
|
{
|
|
"globalHeaders": [
|
|
{
|
|
"pattern": "/api/modules/sirsoft-ecommerce/*",
|
|
"headers": {
|
|
"X-Cart-Key": "{{_global.cartKey}}",
|
|
"X-User-Locale": "{{_global.locale ?? 'ko'}}"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
**지원되는 컨텍스트**:
|
|
|
|
| 컨텍스트 | 설명 |
|
|
|----------|------|
|
|
| `{{_global.xxx}}` | 전역 상태 값 |
|
|
| `{{_local.xxx}}` | 로컬 상태 값 |
|
|
| `{{route.xxx}}` | 라우트 파라미터 |
|
|
| `{{query.xxx}}` | 쿼리 파라미터 |
|
|
|
|
### 헤더 우선순위
|
|
|
|
```text
|
|
낮음 ← globalHeaders ← source.headers / params.headers → 높음
|
|
```
|
|
|
|
개별 API의 `headers` 속성이 `globalHeaders`의 동일한 키를 덮어씁니다:
|
|
|
|
```json
|
|
{
|
|
"globalHeaders": [
|
|
{ "pattern": "*", "headers": { "X-Custom": "global-value" } }
|
|
],
|
|
"data_sources": [
|
|
{
|
|
"id": "products",
|
|
"endpoint": "/api/products",
|
|
"headers": { "X-Custom": "source-value" } // globalHeaders보다 우선
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### 적용 범위
|
|
|
|
| 적용 대상 | 설명 |
|
|
|----------|------|
|
|
| `data_sources` | 레이아웃의 data_sources 배열에서 정의한 API |
|
|
| `apiCall` 핸들러 | actions에서 `handler: "apiCall"`로 호출하는 API |
|
|
| `refetchDataSource` 핸들러 | DataSourceManager를 통해 호출 (자동 적용) |
|
|
|
|
### 상속 시 병합
|
|
|
|
부모-자식 레이아웃 상속 시 globalHeaders는 **pattern 기준으로 병합**됩니다:
|
|
|
|
```text
|
|
부모: [{ pattern: "*", headers: { "X-Template": "basic" } }]
|
|
자식: [{ pattern: "*", headers: { "X-Page": "cart" } }]
|
|
결과: [{ pattern: "*", headers: { "X-Template": "basic", "X-Page": "cart" } }]
|
|
```
|
|
|
|
- 동일 pattern: headers가 병합됨 (자식이 동일 키 덮어씀)
|
|
- 다른 pattern: 별도로 유지
|
|
|
|
> **상세 문서**: [layout-json-inheritance.md](layout-json-inheritance.md#globalheaders-병합)
|
|
|
|
### 주의사항
|
|
|
|
```text
|
|
표현식 값이 빈 문자열/undefined/null이면 해당 헤더는 전송되지 않음
|
|
_isolated 컨텍스트는 지원하지 않음 (data_sources fetch 시점에 격리 스코프 미확정)
|
|
시스템 내부 API(레이아웃 로드, 번역 파일 등)에는 적용되지 않음
|
|
```
|
|
|
|
---
|
|
|
|
## 새 속성 추가 규정
|
|
|
|
```text
|
|
주의: 레이아웃 JSON에 새로운 최상위 속성 추가 시 백엔드 작업 필수
|
|
필수: UpdateLayoutContentRequest rules()에도 새 속성 추가
|
|
프론트엔드만 수정하고 백엔드 누락 시 저장 후 데이터 사라짐
|
|
```
|
|
|
|
### 왜 필요한가?
|
|
|
|
Laravel의 `FormRequest::validated()` 메서드는 `rules()`에 정의된 필드만 반환합니다. 따라서:
|
|
|
|
1. 레이아웃 JSON에 새 속성을 추가해도
|
|
2. `UpdateLayoutContentRequest::rules()`에 없으면
|
|
3. 저장 시 해당 속성이 **삭제**됩니다
|
|
|
|
### 새 속성 추가 체크리스트
|
|
|
|
```
|
|
□ 프론트엔드: 레이아웃 JSON에 새 속성 추가
|
|
□ 백엔드: UpdateLayoutContentRequest::rules()에 규칙 추가
|
|
□ 백엔드: UpdateLayoutContentRequest::messages()에 에러 메시지 추가
|
|
□ 다국어: lang/ko/validation.php, lang/en/validation.php 메시지 추가
|
|
□ 테스트: 해당 속성이 저장되는지 테스트 추가
|
|
```
|
|
|
|
### 파일 위치
|
|
|
|
- FormRequest: `app/Http/Requests/Layout/UpdateLayoutContentRequest.php`
|
|
- 다국어: `lang/ko/validation.php`, `lang/en/validation.php`
|
|
- 테스트: `tests/Feature/Api/Admin/LayoutControllerTest.php`
|
|
|
|
### 예시: 새 속성 `custom_field` 추가
|
|
|
|
```php
|
|
// UpdateLayoutContentRequest::rules()
|
|
'content.custom_field' => ['nullable', 'array'],
|
|
|
|
// UpdateLayoutContentRequest::messages()
|
|
'content.custom_field.array' => __('validation.layout.custom_field.array'),
|
|
```
|
|
|
|
---
|
|
|
|
## 컴포넌트 정의
|
|
|
|
```json
|
|
{
|
|
"id": "unique_id",
|
|
"type": "basic|composite|layout",
|
|
"name": "ComponentName",
|
|
"props": {
|
|
"title": "$t:dashboard.title",
|
|
"value": "{{data.field}}"
|
|
},
|
|
"children": [],
|
|
"actions": [
|
|
{
|
|
"type": "click",
|
|
"handler": "navigate",
|
|
"target": "/path"
|
|
}
|
|
],
|
|
"data_binding": {
|
|
"value": "data.field"
|
|
}
|
|
}
|
|
```
|
|
|
|
**필드 설명**:
|
|
|
|
| 필드 | 타입 | 필수 | 설명 |
|
|
|------|------|------|------|
|
|
| `id` | string | ✅ | 컴포넌트 고유 ID |
|
|
| `type` | string | ✅ | 컴포넌트 타입 (basic/composite/layout) |
|
|
| `name` | string | ✅ | 컴포넌트 이름 (components.json에 등록된 이름) |
|
|
| `comment` | string | ✅ | 시안 추적 코드 및 역할 설명 (예: "F-002: 상품명 검색 필드"). 공개 서빙 응답에서는 제거됨 (아래 참조) |
|
|
| `props` | object | ❌ | 컴포넌트에 전달할 props |
|
|
| `text` | string | ❌ | 텍스트 콘텐츠 (최우선 렌더링) |
|
|
| `children` | array | ❌ | 자식 컴포넌트 배열 (레이아웃/집합 컴포넌트에서 사용) |
|
|
| `iteration` | object | ❌ | 반복 렌더링 설정 (source, item_var, index_var) |
|
|
| `actions` | array | ❌ | 액션 핸들러 정의 (클릭, 키보드, 폼 제출 등) |
|
|
| `lifecycle` | object | ❌ | 생명주기 핸들러 (onMount, onUnmount) |
|
|
| `data_binding` | object | ❌ | 데이터 바인딩 정의 |
|
|
| `blur_until_loaded` | boolean \| string | ❌ | 데이터 로딩 중 blur 효과 적용 (boolean 또는 표현식 문자열) |
|
|
| `component_layout` | object | ❌ | 컴포넌트 내부 커스텀 렌더링 레이아웃 정의 (RichSelect 등에서 사용) |
|
|
| `isolatedState` | object | ❌ | 격리된 상태 초기값 정의 (engine-v1.14.0+) |
|
|
| `isolatedScopeId` | string | ❌ | 격리된 스코프 식별자 (engine-v1.14.0+) |
|
|
| `autoBinding` | boolean | ❌ | `false`로 설정 시 해당 필드의 폼 자동 바인딩을 명시적으로 비활성화. 커스텀 핸들러나 G7Core API로 상태 관리 시 사용 (engine-v1.17.6+) |
|
|
|
|
> **상세 문서**:
|
|
> - `iteration`: [layout-json-components.md](layout-json-components.md#반복-렌더링-iteration)
|
|
> - `blur_until_loaded`: [layout-json-components.md](layout-json-components.md#데이터-로딩-중-blur-효과-blur_until_loaded)
|
|
> - `lifecycle`: [layout-json-components.md](layout-json-components.md#컴포넌트-생명주기-lifecycle)
|
|
> - `actions`: [layout-json-features.md](layout-json-features.md#액션-시스템-actions)
|
|
> - `isolatedState`: [아래 섹션](#격리된-상태-isolatedstate)
|
|
|
|
### `comment` / `_comment` — 공개 서빙 응답에서 제거
|
|
|
|
`comment` / `_comment` 는 편집기·개발자용 설명 주석이며 런타임 렌더러는 참조하지 않는다. 공개 레이아웃 서빙(`GET /api/layouts/{template}/{layout}.json`) 응답에서는 전송 크기 절감을 위해 이 두 키가 노드 트리 전체에서 재귀적으로 제거된다(`LayoutService::stripDeveloperComments`).
|
|
|
|
- 편집 모드 서빙(`?with_source_meta=1`, 편집 권한 필요)에서는 편집기가 `comment` 를 편집 가능한 속성으로 노출하므로 **보존**된다. 공개/편집 응답은 별도 캐시 키로 분리되어 있어 조건부 제거가 안전하다.
|
|
- 따라서 `comment` 는 편집기·저장본에서는 유지되고, 일반 사용자에게 서빙되는 응답에서만 사라진다. 레이아웃 로직이 `comment` 값에 의존해서는 안 된다.
|
|
|
|
---
|
|
|
|
## 격리된 상태 (isolatedState)
|
|
|
|
> **버전**: engine-v1.14.0+
|
|
|
|
특정 컴포넌트 영역 내에서만 유효한 격리된 상태를 정의합니다. 해당 영역의 상태 변경 시 전체 레이아웃이 아닌 격리된 영역만 리렌더링되어 성능이 향상됩니다.
|
|
|
|
### 기본 문법
|
|
|
|
```json
|
|
{
|
|
"type": "Div",
|
|
"isolatedState": {
|
|
"selectedCategories": [],
|
|
"currentStep": 1,
|
|
"isExpanded": false
|
|
},
|
|
"isolatedScopeId": "category-selector",
|
|
"children": [...]
|
|
}
|
|
```
|
|
|
|
### 속성 설명
|
|
|
|
| 속성 | 타입 | 필수 | 설명 |
|
|
| ---- | ---- | ---- | ---- |
|
|
| `isolatedState` | object | ✅ | 격리된 상태의 초기값을 정의하는 객체 |
|
|
| `isolatedScopeId` | string | ❌ | 외부에서 접근할 때 사용하는 스코프 식별자 |
|
|
|
|
### 상태 접근 방법
|
|
|
|
격리된 상태는 `_isolated` 접두사로 접근합니다:
|
|
|
|
```json
|
|
{
|
|
"type": "basic",
|
|
"name": "Button",
|
|
"props": {
|
|
"disabled": "{{_isolated.currentStep === 1}}"
|
|
},
|
|
"text": "{{_isolated.selectedCategories.length}}개 선택됨"
|
|
}
|
|
```
|
|
|
|
### 상태 업데이트
|
|
|
|
`setState` 핸들러에서 `target: "isolated"` 옵션을 사용합니다:
|
|
|
|
```json
|
|
{
|
|
"type": "click",
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "isolated",
|
|
"currentStep": "{{_isolated.currentStep + 1}}"
|
|
}
|
|
}
|
|
```
|
|
|
|
### 사용 시점
|
|
|
|
| 상황 | 권장 상태 | 이유 |
|
|
| ---- | --------- | ---- |
|
|
| 빈번한 사용자 인터랙션 | `_isolated` | 리렌더링 최소화 |
|
|
| 독립적인 UI 영역 | `_isolated` | 다른 영역과 무관한 상태 |
|
|
| 레이아웃 전체에서 공유 필요 | `_local` | 여러 컴포넌트에서 접근 |
|
|
| 페이지 이동 후에도 유지 필요 | `_global` | 앱 전역 상태 |
|
|
|
|
### 라이프사이클
|
|
|
|
```text
|
|
1. 생성: isolatedState 속성이 있는 컴포넌트 마운트 시
|
|
2. 업데이트: setState target:"isolated" 또는 G7Core.state.setIsolated()
|
|
3. 소멸: 해당 컴포넌트 언마운트 시
|
|
4. 페이지 이동: 초기화됨 (언마운트 → 재마운트)
|
|
```
|
|
|
|
### 주의사항
|
|
|
|
```text
|
|
isolatedState 외부에서 target: "isolated" 사용 시 → _local로 폴백 + 경고 로그
|
|
isolatedScopeId 중복 시 → 마지막 등록된 스코프 우선 + 경고 로그
|
|
중첩 isolatedState 시 → 가장 가까운 스코프 사용
|
|
```
|
|
|
|
### 관련 문서
|
|
|
|
- [상태 관리 - _isolated](state-management.md#격리된-상태-_isolated)
|
|
- [액션 핸들러 - target: isolated](actions-handlers-state.md#격리된-상태-변경-target-isolated)
|
|
- [G7Core API - getIsolated/setIsolated](g7core-api.md#getisolated--setisolated-v1140)
|
|
|
|
---
|
|
|
|
## 텍스트 렌더링
|
|
|
|
```
|
|
중요: 컴포넌트에 텍스트를 표시할 때는 text 속성을 사용해야 합니다.
|
|
필수: text 속성 사용 (DynamicRenderer가 최우선 처리)
|
|
필수: children 배열 사용 (하위 컴포넌트 포함)
|
|
주의: props.children 사용 불가 (무시됨)
|
|
```
|
|
|
|
### 핵심 원칙
|
|
|
|
그누보드7 템플릿 엔진의 DynamicRenderer는 다음 우선순위로 렌더링합니다:
|
|
|
|
1. **`text` 속성** (최우선) - 텍스트 콘텐츠 표시
|
|
2. **`children` 배열** - 하위 컴포넌트 렌더링
|
|
3. **`props`** - 일반 속성 전달
|
|
|
|
### 올바른 패턴
|
|
|
|
```json
|
|
{
|
|
"id": "page_title",
|
|
"type": "basic",
|
|
"name": "H1",
|
|
"props": {
|
|
"className": "text-2xl font-bold"
|
|
},
|
|
"text": "대시보드"
|
|
}
|
|
```
|
|
|
|
```json
|
|
{
|
|
"id": "logo_text",
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"props": {
|
|
"className": "text-white font-bold"
|
|
},
|
|
"text": "G7"
|
|
}
|
|
```
|
|
|
|
### 잘못된 패턴
|
|
|
|
```json
|
|
{
|
|
"id": "page_title",
|
|
"type": "basic",
|
|
"name": "H1",
|
|
"props": {
|
|
"className": "text-2xl font-bold",
|
|
"children": "대시보드" // ❌ props.children은 무시됨
|
|
}
|
|
}
|
|
```
|
|
|
|
### 다국어 지원
|
|
|
|
`text` 속성에서도 다국어 키를 사용할 수 있습니다:
|
|
|
|
```json
|
|
{
|
|
"id": "page_title",
|
|
"type": "basic",
|
|
"name": "H1",
|
|
"props": {
|
|
"className": "text-2xl font-bold"
|
|
},
|
|
"text": "$t:dashboard.title"
|
|
}
|
|
```
|
|
|
|
### 데이터 바인딩 지원
|
|
|
|
`text` 속성에서 데이터 바인딩도 가능합니다:
|
|
|
|
```json
|
|
{
|
|
"id": "user_name",
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"text": "{{user.name}}"
|
|
}
|
|
```
|
|
|
|
### children 배열과의 차이
|
|
|
|
```json
|
|
// ✅ text 속성: 단순 텍스트 표시
|
|
{
|
|
"id": "title",
|
|
"type": "basic",
|
|
"name": "H1",
|
|
"text": "제목"
|
|
}
|
|
|
|
// ✅ children 배열: 하위 컴포넌트 포함
|
|
{
|
|
"id": "card",
|
|
"type": "composite",
|
|
"name": "Card",
|
|
"children": [
|
|
{
|
|
"id": "card_title",
|
|
"type": "basic",
|
|
"name": "H2",
|
|
"text": "카드 제목"
|
|
},
|
|
{
|
|
"id": "card_content",
|
|
"type": "basic",
|
|
"name": "P",
|
|
"text": "카드 내용"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### DynamicRenderer 동작 방식
|
|
|
|
```typescript
|
|
// DynamicRenderer.tsx (코어 엔진)
|
|
const renderChildren = useMemo(() => {
|
|
// 1. text 속성이 있으면 최우선으로 사용
|
|
if (componentDef.text !== undefined) {
|
|
return componentDef.text;
|
|
}
|
|
|
|
// 2. children이 없으면 null
|
|
if (!componentDef.children || componentDef.children.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// 3. children 배열 렌더링
|
|
return componentDef.children.map((childDef, index) => {
|
|
// ...
|
|
});
|
|
}, [componentDef.text, componentDef.children]);
|
|
```
|
|
|
|
### 주의사항
|
|
|
|
- ❌ `props.children`은 DynamicRenderer에서 완전히 무시됩니다
|
|
- ✅ React 컴포넌트 개발 시 `children` prop은 정상 작동 (TSX 내부)
|
|
- ✅ 레이아웃 JSON에서는 `text` 속성과 `children` 배열만 사용
|
|
|
|
### 실제 사용 예시
|
|
|
|
```json
|
|
{
|
|
"id": "sidebar_logo",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "flex items-center gap-3"
|
|
},
|
|
"children": [
|
|
{
|
|
"id": "logo_circle",
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "flex items-center justify-center w-10 h-10 bg-black rounded-full"
|
|
},
|
|
"children": [
|
|
{
|
|
"id": "logo_text",
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"props": {
|
|
"className": "text-white font-bold text-sm"
|
|
},
|
|
"text": "G7"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"id": "app_name",
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"props": {
|
|
"className": "font-semibold text-lg"
|
|
},
|
|
"text": "G7"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Extension Point 데이터 전달 (engine-v1.15.0+, callbacks: engine-v1.28.0+)
|
|
|
|
`extension_point`에서 주입되는 컴포넌트에 데이터와 콜백을 전달합니다.
|
|
|
|
- `props`: 데이터 전달용 — 표현식이 평가되어 전달됨 (`resolveObject` 재귀 평가)
|
|
- `callbacks`: 액션 객체 전달용 — 평가 없이 그대로 전달 (ActionDispatcher가 실행 시점에 평가)
|
|
|
|
### 호스트 레이아웃 (extension_point 정의)
|
|
|
|
```json
|
|
{
|
|
"type": "extension_point",
|
|
"name": "address_search_slot",
|
|
"props": {
|
|
"productId": "{{route.id}}",
|
|
"readOnlyFields": ["zipcode", "address"]
|
|
},
|
|
"callbacks": {
|
|
"onAddressSelect": {
|
|
"handler": "setState",
|
|
"params": { "target": "local", "form.zipcode": "{{$event.zipcode}}" }
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 플러그인 extension JSON (주입되는 컴포넌트)
|
|
|
|
```json
|
|
{
|
|
"handler": "myPlugin.doSomething",
|
|
"params": {
|
|
"id": "{{extensionPointProps.productId}}",
|
|
"fields": "{{extensionPointProps.readOnlyFields}}",
|
|
"callbackAction": "{{extensionPointCallbacks.onAddressSelect}}"
|
|
}
|
|
}
|
|
```
|
|
|
|
- `{{extensionPointProps.xxx}}`: 호스트의 `props`에서 전달된 데이터 (표현식 평가됨)
|
|
- `{{extensionPointCallbacks.xxx}}`: 호스트의 `callbacks`에서 전달된 액션 객체 (그대로 전달)
|
|
|
|
| 필드 | 타입 | 필수 | 설명 |
|
|
|------|------|------|------|
|
|
| `props` | object | ❌ | 주입 컴포넌트에 전달할 데이터 (표현식 평가) |
|
|
| `callbacks` | object | ❌ | 주입 컴포넌트에 전달할 액션 객체 (평가 없이 전달) |
|
|
|
|
전달된 값은 주입 컴포넌트와 그 자손 전체에서 참조할 수 있습니다.
|
|
|
|
검색 봇용 화면에서는 `extensionPointProps` 만 해석되고 `extensionPointCallbacks` 는 해석되지 않습니다. 색인되어야 할 내용은 `props` 로 전달하세요. 사용자 작성 콘텐츠를 넘길 때는 평문/HTML 판정 값(`isHtml`)을 함께 전달합니다 — 두 화면 모두 이 값에 따라 이스케이프하거나 위험 요소를 제거한 뒤 출력합니다. 봇 화면의 노드 문법 지원 범위는 [seo-system.md "SEO 렌더러 지원 노드 키"](../backend/seo-system.md), 정화 규칙은 같은 문서의 "봇 화면의 HTML 정화" 를 참조하세요.
|
|
|
|
---
|
|
|
|
## Deprecated 속성
|
|
|
|
하위 호환성을 위해 별칭이 유지되는 속성입니다. 새 코드에서는 권장 속성을 사용하세요.
|
|
|
|
| Deprecated | 권장 속성 | 호환성 | 비고 |
|
|
|-----------|----------|--------|------|
|
|
| `init_actions` | `initActions` | 별칭 유지 (engine-v1.0.0+) | 카멜케이스 통일 |
|
|
| `state` | `initLocal` | 별칭 유지 (engine-v1.11.0+) | `initLocal`이 더 명시적 |
|
|
| `auth_required` | `auth_mode` | 별칭 유지 (engine-v1.0.0+) | `auth_mode`가 더 세밀한 제어 지원 |
|
|
|
|
```text
|
|
기존 레이아웃에서 deprecated 속성 사용 시 정상 동작 (별칭 유지)
|
|
✅ 신규 레이아웃에서는 권장 속성만 사용
|
|
✅ 리팩토링 시 권장 속성으로 변경 권장
|
|
```
|
|
|
|
---
|
|
|
|
## 관련 문서
|
|
|
|
- [컴포넌트 개발 규칙](components.md) - basic, composite, layout 컴포넌트
|
|
- [데이터 바인딩](data-binding.md) - `{{}}` 표현식, `$t:` 다국어
|
|
- [데이터 소스](data-sources.md) - API 데이터 자동 fetch
|
|
- [상태 관리](state-management.md) - `_global` 전역 상태
|