998 lines
28 KiB
Markdown
998 lines
28 KiB
Markdown
# 액션 핸들러 - 상태 관리
|
|
|
|
> **메인 문서**: [actions-handlers.md](actions-handlers.md)
|
|
|
|
---
|
|
|
|
## 목차
|
|
|
|
1. [apiCall](#apicall)
|
|
2. [setState](#setstate)
|
|
3. [setError](#seterror)
|
|
4. [refetchDataSource](#refetchdatasource)
|
|
5. [updateDataSource](#updatedatasource)
|
|
6. [appendDataSource](#appenddatasource)
|
|
7. [remount](#remount)
|
|
8. [sortable (드래그앤드롭 정렬)](#sortable-드래그앤드롭-정렬)
|
|
9. [onSuccess/onError 후속 액션](#onsuccessonerror-후속-액션)
|
|
10. [API 데이터 바인딩 규칙](#api-데이터-바인딩-규칙)
|
|
11. [에러 핸들링 시스템](#에러-핸들링-시스템-errorhandling)
|
|
|
|
---
|
|
|
|
## apiCall
|
|
|
|
API를 호출합니다. **주의: `api`가 아닌 `apiCall`을 사용해야 합니다.**
|
|
|
|
```json
|
|
{
|
|
"type": "click",
|
|
"handler": "apiCall",
|
|
"auth_required": true,
|
|
"target": "/api/admin/users/bulk-status",
|
|
"params": {
|
|
"method": "PATCH",
|
|
"body": {
|
|
"ids": "{{_global.selectedIds}}",
|
|
"status": "active"
|
|
}
|
|
},
|
|
"onSuccess": [
|
|
{ "handler": "closeModal" },
|
|
{ "handler": "toast", "params": { "type": "success", "message": "$t:common.success" } }
|
|
]
|
|
}
|
|
```
|
|
|
|
### 액션 레벨 속성
|
|
|
|
| 필드 | 타입 | 기본값 | 설명 |
|
|
|------|------|--------|------|
|
|
| `auth_required` | boolean | false | true이면 Bearer 토큰을 Authorization 헤더에 포함 (401 에러 발생 시 로그인 페이지 리다이렉트) |
|
|
| `auth_mode` | string | "required" | 인증 모드: `"required"` (토큰 없으면 에러), `"optional"` (토큰 있으면 포함, 없어도 진행) |
|
|
| `identity_target` | object | — | 본인인증(IDV) 대상. 이 apiCall이 HTTP 428(본인인증 필요)을 받으면 인증 코드/링크를 보낼 `{ email?, phone? }` 을 흐름이 직접 선언 (engine-v1.51.0+) |
|
|
|
|
```text
|
|
중요: auth_required, auth_mode, identity_target은 params 안이 아닌 액션 정의 최상위에 선언해야 합니다.
|
|
```
|
|
|
|
#### identity_target 사용 예시
|
|
|
|
본인인증 정책이 켜진 상태에서 비로그인(게스트) 사용자가 호출하는 API는, 인증 코드를 보낼 대상(이메일·전화)을 화면 입력값에서 선언해야 합니다. 서버는 428을 던지는 시점에 사용자가 방금 화면에 입력한 값을 알 수 없으므로, 대상 수집은 레이아웃의 책임입니다.
|
|
|
|
```json
|
|
{
|
|
"handler": "apiCall",
|
|
"target": "/api/modules/sirsoft-ecommerce/user/orders",
|
|
"auth_mode": "optional",
|
|
"identity_target": {
|
|
"email": "{{_local.orderer?.email || ''}}",
|
|
"phone": "{{_local.orderer?.phone || _local.shipping?.recipient_phone || ''}}"
|
|
},
|
|
"params": { "method": "POST", "body": { } }
|
|
}
|
|
```
|
|
|
|
- `email` / `phone` 둘 중 하나만 있어도 충분합니다 (서버가 둘 다 허용).
|
|
- 우선순위는 표현식 자체가 결정합니다 (예: 주문자 정보 우선 → 수취인 정보 폴백).
|
|
- 표현식은 `|| ''` fallback으로 빈 문자열을 보장합니다 (undefined 방지).
|
|
- 로그인 사용자는 빈 값이어도 서버가 세션에서 대상을 도출하므로 무방합니다.
|
|
- `G7Core.api`(axios) 직접 호출 경로에서는 호출 config에 `identity_target` 을 실어 동일하게 동작합니다.
|
|
|
|
#### auth_mode 사용 예시
|
|
|
|
비회원도 접근 가능하지만, 로그인 시 추가 기능을 제공하는 API:
|
|
|
|
```json
|
|
{
|
|
"handler": "apiCall",
|
|
"target": "/api/cart",
|
|
"auth_mode": "optional",
|
|
"params": { "method": "GET" }
|
|
}
|
|
```
|
|
|
|
| auth_mode | 토큰 있음 | 토큰 없음 |
|
|
|-----------|----------|----------|
|
|
| `"required"` (기본) | Bearer 토큰 전송 | 에러 발생 |
|
|
| `"optional"` | Bearer 토큰 전송 | 토큰 없이 요청 진행 |
|
|
|
|
```json
|
|
// ✅ 올바른 사용
|
|
{
|
|
"handler": "apiCall",
|
|
"auth_required": true,
|
|
"target": "/api/admin/users",
|
|
"params": { "method": "POST" }
|
|
}
|
|
|
|
// ❌ 잘못된 사용 (Bearer 토큰이 전송되지 않음)
|
|
{
|
|
"handler": "apiCall",
|
|
"target": "/api/admin/users",
|
|
"params": { "method": "POST", "auth_required": true }
|
|
}
|
|
```
|
|
|
|
### apiCall params 구조
|
|
|
|
| 필드 | 타입 | 기본값 | 설명 |
|
|
|------|------|--------|------|
|
|
| `method` | string | "GET" | HTTP 메서드 (GET, POST, PUT, PATCH, DELETE) |
|
|
| `body` | object | - | 요청 본문 (JSON 또는 FormData) |
|
|
| `headers` | object | - | 추가 헤더 |
|
|
| `contentType` | string | "application/json" | 요청 Content-Type (`"multipart/form-data"` 지정 시 FormData 자동 변환) |
|
|
|
|
### multipart/form-data 지원 (파일 업로드)
|
|
|
|
> **버전**: engine-v1.19.0+
|
|
|
|
`contentType: "multipart/form-data"`를 지정하면 `body`가 자동으로 `FormData`로 변환됩니다.
|
|
`Content-Type` 헤더는 설정하지 않으며 (브라우저가 boundary를 포함하여 자동 설정), File/Blob 객체는 원본 그대로 전송됩니다.
|
|
|
|
```json
|
|
{
|
|
"handler": "apiCall",
|
|
"auth_required": true,
|
|
"target": "/api/admin/modules/manual-install",
|
|
"params": {
|
|
"method": "POST",
|
|
"contentType": "multipart/form-data",
|
|
"body": {
|
|
"file": "{{_global.moduleUploadFile}}",
|
|
"source": "file_upload"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
#### FormData 변환 규칙
|
|
|
|
| body 값 타입 | FormData 처리 |
|
|
|-------------|---------------|
|
|
| `File` / `Blob` | `formData.append(key, value)` (원본 유지) |
|
|
| `string` / `number` / `boolean` | `formData.append(key, String(value))` |
|
|
| `object` / `array` | `formData.append(key, JSON.stringify(value))` |
|
|
| `null` / `undefined` | 제외 (전송하지 않음) |
|
|
|
|
#### 주의사항
|
|
|
|
```text
|
|
주의: contentType: "multipart/form-data" 시 Content-Type 헤더를 수동 설정하지 말 것
|
|
→ 브라우저가 boundary를 포함한 Content-Type을 자동 생성
|
|
주의: File/Blob 객체는 setState로 저장 시 원본 참조 유지됨 (engine-v1.19.0+)
|
|
→ deepMergeWithState가 non-plain 객체(File, Blob, Date 등)를 spread 없이 직접 할당
|
|
필수: 파일 업로드 API는 백엔드에서 multipart/form-data를 기대해야 함
|
|
```
|
|
|
|
### apiCall body에서 조건부 필드 제외 (undefined 패턴)
|
|
|
|
드라이버/모드 선택에 따라 **특정 필드만 전송**해야 하는 경우, `undefined`를 반환하는 삼항 표현식을 사용합니다. `JSON.stringify()`는 값이 `undefined`인 키를 자동으로 제거합니다.
|
|
|
|
```json
|
|
{
|
|
"handler": "apiCall",
|
|
"target": "/api/admin/settings/test-mail",
|
|
"params": {
|
|
"method": "POST",
|
|
"body": {
|
|
"mailer": "{{_local.form?.mail?.mailer || 'smtp'}}",
|
|
"from_address": "{{_local.form?.mail?.from_address || ''}}",
|
|
"host": "{{_local.form?.mail?.mailer === 'smtp' ? (_local.form?.mail?.host ?? '') : undefined}}",
|
|
"mailgun_domain": "{{_local.form?.mail?.mailer === 'mailgun' ? (_local.form?.mail?.mailgun_domain ?? '') : undefined}}"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**동작 원리**:
|
|
|
|
| 조건 | 표현식 결과 | JSON 직렬화 |
|
|
|------|------------|-------------|
|
|
| mailer === 'smtp' | `host: "smtp.example.com"` | `"host":"smtp.example.com"` (포함) |
|
|
| mailer === 'mailgun' | `host: undefined` | 키 자체가 제거됨 |
|
|
|
|
**핵심 규칙**:
|
|
|
|
```text
|
|
주의: 불필요한 필드를 빈 문자열('')로 전송하지 말 것
|
|
필수: undefined 패턴으로 해당 드라이버 필드만 전송
|
|
공통 필드(from_address, from_name 등)는 조건 없이 항상 전송
|
|
주의: 모든 드라이버 필드를 항상 전송하면 서버 측 불필요한 처리 유발
|
|
```
|
|
|
|
**삼항 표현식 패턴**:
|
|
|
|
```text
|
|
현재 드라이버 필드: 조건 ? (값 ?? 기본값) : undefined
|
|
다른 드라이버 필드: undefined (JSON에서 제거)
|
|
공통 필드: 조건 없이 항상 포함
|
|
```
|
|
|
|
---
|
|
|
|
### CSRF 토큰
|
|
|
|
`apiCall`은 자동으로 CSRF 토큰을 처리합니다:
|
|
- POST, PUT, PATCH, DELETE 요청 시 `/sanctum/csrf-cookie` 호출
|
|
- 쿠키에서 `XSRF-TOKEN`을 추출하여 `X-XSRF-TOKEN` 헤더에 포함
|
|
|
|
### globalHeaders 자동 적용
|
|
|
|
> **버전**: engine-v1.16.0+
|
|
|
|
레이아웃에 `globalHeaders`가 정의되어 있으면, `apiCall` 핸들러도 해당 패턴에 매칭되는 API에 헤더를 자동으로 포함합니다.
|
|
|
|
```json
|
|
// 레이아웃 최상위
|
|
{
|
|
"globalHeaders": [
|
|
{ "pattern": "/api/modules/sirsoft-ecommerce/*", "headers": { "X-Cart-Key": "{{_global.cartKey}}" } }
|
|
]
|
|
}
|
|
|
|
// apiCall 핸들러 - X-Cart-Key 헤더 자동 포함
|
|
{
|
|
"handler": "apiCall",
|
|
"target": "/api/modules/sirsoft-ecommerce/cart/add",
|
|
"params": {
|
|
"method": "POST",
|
|
"body": { "productId": "{{item.id}}" }
|
|
}
|
|
}
|
|
```
|
|
|
|
**헤더 우선순위**: `params.headers` > `globalHeaders`
|
|
|
|
```json
|
|
{
|
|
"handler": "apiCall",
|
|
"target": "/api/modules/sirsoft-ecommerce/cart",
|
|
"params": {
|
|
"method": "GET",
|
|
"headers": { "X-Cart-Key": "custom-key" } // globalHeaders보다 우선
|
|
}
|
|
}
|
|
```
|
|
|
|
> **상세 문서**: [layout-json.md](layout-json.md#전역-헤더-globalheaders)
|
|
|
|
---
|
|
|
|
## setState
|
|
|
|
상태를 변경합니다.
|
|
|
|
### 병합 모드 (merge 옵션)
|
|
|
|
> **버전**: engine-v1.18.0+ (`replace` 모드 추가)
|
|
|
|
setState 핸들러의 `params`에 `merge` 속성을 지정하여 상태 병합 방식을 선택할 수 있습니다.
|
|
|
|
| `merge` 값 | 동작 | 사용 시점 |
|
|
|-------------|------|----------|
|
|
| 생략 또는 `"deep"` | 재귀적 깊은 병합 (중첩 객체 보존) | 개별 필드 업데이트 (기본값) |
|
|
| `"shallow"` | 최상위 키만 덮어쓰기 (1단계) | 프리셋 적용, 필터 초기화 |
|
|
| `"replace"` | 기존 상태 완전 무시, 새 값으로 교체 | 폼 초기화, 서버 데이터로 전체 교체 |
|
|
|
|
```json
|
|
// replace 모드: 기존 _local을 완전히 payload로 교체
|
|
{
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "local",
|
|
"merge": "replace",
|
|
"formData": { "name": "", "price": 0 }
|
|
}
|
|
}
|
|
|
|
// shallow 모드: 최상위 키만 덮어쓰기
|
|
{
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "local",
|
|
"merge": "shallow",
|
|
"filter": { "searchField": "all", "status": "all" }
|
|
}
|
|
}
|
|
```
|
|
|
|
> **상세 비교**: [state-management-forms.md](state-management-forms.md#병합-모드-선택)
|
|
|
|
### 전역 상태 변경 (target: "global")
|
|
|
|
```json
|
|
{
|
|
"type": "change",
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "global",
|
|
"selectedIds": "{{$args[0]}}",
|
|
"searchQuery": "{{$event.target.value}}"
|
|
}
|
|
}
|
|
```
|
|
|
|
### 로컬 상태 변경 (target: "local" 또는 생략)
|
|
|
|
```json
|
|
{
|
|
"type": "change",
|
|
"handler": "setState",
|
|
"params": {
|
|
"isExpanded": true
|
|
}
|
|
}
|
|
```
|
|
|
|
### 격리된 상태 변경 (target: "isolated")
|
|
|
|
> **버전**: engine-v1.14.0+
|
|
|
|
`isolatedState` 속성이 정의된 컴포넌트 내에서만 동작합니다. 해당 영역의 상태 변경 시 전체 레이아웃이 아닌 격리된 영역만 리렌더링됩니다.
|
|
|
|
```json
|
|
{
|
|
"type": "click",
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "isolated",
|
|
"selectedId": "{{item.id}}",
|
|
"currentStep": 2
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 사용 요건
|
|
|
|
```text
|
|
주의: target: "isolated"는 isolatedState 속성이 정의된 컴포넌트 내에서만 동작합니다.
|
|
격리 스코프 외부에서 호출 시 → _local로 폴백되며 경고 로그 출력
|
|
isolatedState가 있는 컴포넌트 내에서 호출 시 → 격리된 상태만 업데이트
|
|
```
|
|
|
|
#### 레이아웃 정의 예시
|
|
|
|
```json
|
|
{
|
|
"type": "Div",
|
|
"isolatedState": {
|
|
"selectedCategories": [null, null, null, null],
|
|
"currentStep": 1
|
|
},
|
|
"isolatedScopeId": "category-selector",
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Button",
|
|
"props": {
|
|
"label": "다음 단계",
|
|
"onClick": {
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "isolated",
|
|
"currentStep": "{{_isolated.currentStep + 1}}"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
#### target별 비교
|
|
|
|
| target | 상태 스코프 | 리렌더링 범위 | 사용 시점 |
|
|
| ------ | ----------- | -------------- | ---------- |
|
|
| `local` (기본) | `_local` | 전체 레이아웃 | 일반 폼 데이터, 필터 |
|
|
| `global` | `_global` | 전체 앱 | 사용자 인증, 사이드바 상태 |
|
|
| `isolated` | `_isolated` | 격리된 영역만 | 빈번한 인터랙션 (카테고리 선택, 드래그) |
|
|
|
|
### 주요 사용 패턴
|
|
|
|
```json
|
|
// 체크박스 선택 ID 저장
|
|
{
|
|
"type": "change",
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "global",
|
|
"selectedIds": "{{$args[0]}}"
|
|
}
|
|
}
|
|
|
|
// 검색어 입력 시 상태 저장
|
|
{
|
|
"type": "change",
|
|
"handler": "setState",
|
|
"params": {
|
|
"searchQuery": "{{$event.target.value}}"
|
|
}
|
|
}
|
|
|
|
// 토글 상태 변경
|
|
{
|
|
"type": "click",
|
|
"handler": "setState",
|
|
"params": {
|
|
"isExpanded": "{{!_local.isExpanded}}"
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## setError
|
|
|
|
에러 상태를 설정합니다. `apiError` 상태 키에 저장됩니다.
|
|
|
|
```json
|
|
{
|
|
"handler": "setError",
|
|
"target": "{{error.response.message}}"
|
|
}
|
|
```
|
|
|
|
### target 값
|
|
|
|
| 형식 | 설명 |
|
|
|------|------|
|
|
| `{{error.message}}` | 데이터 바인딩 (에러 객체에서 추출) |
|
|
| `$t:errors.login_failed` | 다국어 키 |
|
|
| `"로그인에 실패했습니다."` | 직접 문자열 |
|
|
|
|
---
|
|
|
|
## refetchDataSource
|
|
|
|
특정 데이터 소스를 다시 fetch합니다.
|
|
|
|
```json
|
|
{
|
|
"handler": "refetchDataSource",
|
|
"params": {
|
|
"dataSourceId": "modules"
|
|
}
|
|
}
|
|
```
|
|
|
|
### refetchDataSource params 구조
|
|
|
|
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|
|
|------|------|------|--------|------|
|
|
| `dataSourceId` | string | ✅ | - | 다시 fetch할 데이터 소스 ID |
|
|
| `sync` | boolean | ❌ | false | true면 즉시 동기 렌더링 |
|
|
|
|
### sync 옵션
|
|
|
|
> **버전**: engine-v1.4.0+
|
|
|
|
`sync: true`를 사용하면 React의 `startTransition` 없이 즉시 동기적으로 렌더링합니다.
|
|
|
|
```json
|
|
{
|
|
"handler": "refetchDataSource",
|
|
"params": {
|
|
"dataSourceId": "admin_menu",
|
|
"sync": true
|
|
}
|
|
}
|
|
```
|
|
|
|
| 케이스 | sync 필요 여부 |
|
|
|--------|---------------|
|
|
| 드래그 앤 드롭 순서 변경 | ✅ `sync: true` |
|
|
| 토글/체크박스 상태 변경 | ✅ `sync: true` |
|
|
| 일반 데이터 갱신 | ❌ 기본값 사용 |
|
|
|
|
### 상태 오버라이드 (engine-v1.17.0+)
|
|
|
|
refetch 시 상태를 임시로 오버라이드하여 데이터소스 파라미터 치환에 반영할 수 있습니다.
|
|
|
|
| 필드 | 타입 | 필수 | 엔진 버전 | 설명 |
|
|
|------|------|------|----------|------|
|
|
| `globalStateOverride` | object | ❌ | engine-v1.17.0+ | `_global` 값 임시 오버라이드 |
|
|
| `localStateOverride` | object | ❌ | engine-v1.19.0+ | `_local` 값 임시 오버라이드 |
|
|
| `isolatedStateOverride` | object | ❌ | engine-v1.19.0+ | `_isolated` 값 임시 오버라이드 |
|
|
|
|
```json
|
|
{
|
|
"handler": "refetchDataSource",
|
|
"params": {
|
|
"dataSourceId": "products",
|
|
"sync": true,
|
|
"globalStateOverride": {
|
|
"currentPage": 1
|
|
},
|
|
"localStateOverride": {
|
|
"filter": { "status": "active" }
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
```text
|
|
오버라이드는 해당 refetch에만 적용 (실제 상태 미변경)
|
|
✅ 상세 문서: data-sources-advanced.md "상태 오버라이드 파라미터" 섹션
|
|
```
|
|
|
|
---
|
|
|
|
## updateDataSource
|
|
|
|
데이터 소스를 직접 업데이트합니다. API 응답을 사용하여 데이터 소스를 갱신할 때 사용합니다. `refetchDataSource`와 달리 추가 API 요청 없이 즉시 데이터를 업데이트합니다.
|
|
|
|
```json
|
|
{
|
|
"handler": "apiCall",
|
|
"target": "/api/checkout",
|
|
"params": { "method": "PUT", "body": "{{_local.checkout}}" },
|
|
"onSuccess": [
|
|
{
|
|
"handler": "updateDataSource",
|
|
"params": {
|
|
"dataSourceId": "checkoutData",
|
|
"data": "{{response}}"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### updateDataSource params 구조
|
|
|
|
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|
|
|------|------|------|--------|------|
|
|
| `dataSourceId` | string | ✅ | - | 업데이트할 데이터 소스 ID |
|
|
| `data` | any | ✅ | - | 새로운 데이터 (API 응답 등) |
|
|
|
|
### refetchDataSource vs updateDataSource
|
|
|
|
| 핸들러 | 동작 | 네트워크 요청 | 사용 시점 |
|
|
|--------|------|--------------|----------|
|
|
| `refetchDataSource` | 데이터 소스의 endpoint를 다시 호출 | ✅ 발생 | 서버에서 최신 데이터를 가져와야 할 때 |
|
|
| `updateDataSource` | 전달받은 data로 직접 교체 | ❌ 없음 | PUT/POST 응답으로 즉시 갱신할 때 |
|
|
|
|
### 사용 예시
|
|
|
|
**PUT API 응답으로 데이터 소스 갱신** (추가 GET 요청 없이):
|
|
|
|
```json
|
|
{
|
|
"handler": "apiCall",
|
|
"target": "/api/modules/sirsoft-ecommerce/checkout",
|
|
"params": { "method": "PUT", "body": "{{_local.checkout}}" },
|
|
"onSuccess": [
|
|
{
|
|
"handler": "updateDataSource",
|
|
"params": {
|
|
"dataSourceId": "checkoutData",
|
|
"data": "{{response}}"
|
|
}
|
|
},
|
|
{
|
|
"handler": "toast",
|
|
"params": { "type": "success", "message": "$t:common.saved" }
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
```text
|
|
주의: onSuccess 콜백에서 response 변수를 사용할 때 $response가 아닌 response 사용
|
|
"data": "{{$response}}" → undefined로 평가됨
|
|
"data": "{{response}}" → 정상 동작
|
|
```
|
|
|
|
---
|
|
|
|
## appendDataSource
|
|
|
|
기존 데이터 소스에 새 데이터를 병합합니다. 무한스크롤 구현에 유용합니다.
|
|
|
|
```json
|
|
{
|
|
"handler": "appendDataSource",
|
|
"params": {
|
|
"dataSourceId": "templates",
|
|
"dataPath": "data",
|
|
"newData": "{{response.data}}"
|
|
}
|
|
}
|
|
```
|
|
|
|
### appendDataSource params 구조
|
|
|
|
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|
|
|------|------|------|--------|------|
|
|
| `dataSourceId` | string | ✅ | - | 대상 데이터 소스 ID |
|
|
| `dataPath` | string | ❌ | null | 병합할 데이터 경로 (예: "data") |
|
|
| `newData` | array | ✅ | - | 병합할 새 데이터 배열 |
|
|
|
|
### 무한스크롤 예시
|
|
|
|
```json
|
|
{
|
|
"type": "scroll",
|
|
"debounce": 200,
|
|
"handler": "sequence",
|
|
"actions": [
|
|
{
|
|
"handler": "switch",
|
|
"params": {
|
|
"value": "{{$event.target.scrollHeight - $event.target.scrollTop <= $event.target.clientHeight + 100 && _global.infiniteScroll.hasMore && !_global.infiniteScroll.isLoadingMore}}"
|
|
},
|
|
"cases": {
|
|
"true": {
|
|
"handler": "sequence",
|
|
"actions": [
|
|
{
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "global",
|
|
"infiniteScroll.isLoadingMore": true
|
|
}
|
|
},
|
|
{
|
|
"handler": "apiCall",
|
|
"auth_required": true,
|
|
"target": "/api/items",
|
|
"params": {
|
|
"method": "GET",
|
|
"query": {
|
|
"page": "{{_global.infiniteScroll.currentPage + 1}}",
|
|
"per_page": 20
|
|
}
|
|
},
|
|
"onSuccess": [
|
|
{
|
|
"handler": "appendDataSource",
|
|
"params": {
|
|
"dataSourceId": "items",
|
|
"dataPath": "data",
|
|
"newData": "{{response.data}}"
|
|
}
|
|
},
|
|
{
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "global",
|
|
"infiniteScroll.currentPage": "{{_global.infiniteScroll.currentPage + 1}}",
|
|
"infiniteScroll.hasMore": "{{(response.data?.length ?? 0) >= 20}}",
|
|
"infiniteScroll.isLoadingMore": false
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### scroll 이벤트 속성
|
|
|
|
scroll 이벤트에서 `$event.target`으로 접근 가능한 속성:
|
|
|
|
| 속성 | 타입 | 설명 |
|
|
|------|------|------|
|
|
| `scrollHeight` | number | 전체 콘텐츠 높이 |
|
|
| `scrollTop` | number | 현재 스크롤 위치 (상단 기준) |
|
|
| `clientHeight` | number | 보이는 영역 높이 |
|
|
| `scrollWidth` | number | 전체 콘텐츠 너비 |
|
|
| `scrollLeft` | number | 현재 스크롤 위치 (좌측 기준) |
|
|
| `clientWidth` | number | 보이는 영역 너비 |
|
|
|
|
---
|
|
|
|
## remount
|
|
|
|
컴포넌트를 강제로 리마운트합니다.
|
|
|
|
```json
|
|
{
|
|
"handler": "remount",
|
|
"params": {
|
|
"componentId": "template_card_grid"
|
|
}
|
|
}
|
|
```
|
|
|
|
### 사용 사례
|
|
|
|
- Toggle/Checkbox 상태 복원
|
|
- 폼 초기화
|
|
- 컴포넌트 상태 리셋
|
|
|
|
---
|
|
|
|
## sortable (드래그앤드롭 정렬)
|
|
|
|
> **버전**: engine-v1.14.0+
|
|
> **기반**: @dnd-kit/core + @dnd-kit/sortable (React 네이티브 D&D 라이브러리)
|
|
|
|
레이아웃 JSON의 `sortable` 속성을 사용하여 드래그앤드롭 정렬을 구현합니다.
|
|
HTML5 네이티브 D&D가 아닌 @dnd-kit 기반으로 동작하며, DynamicRenderer가 자동으로 SortableContainer/SortableItemWrapper를 렌더링합니다.
|
|
|
|
### sortable 속성 구조
|
|
|
|
| 필드 | 타입 | 필수 | 기본값 | 설명 |
|
|
| ---- | ---- | ---- | ------ | ---- |
|
|
| `source` | string | ✅ | - | 배열 바인딩 표현식 (예: `"{{_local.form.options}}"`) |
|
|
| `itemKey` | string | ❌ | `"id"` | 아이템 고유 키 필드명 |
|
|
| `strategy` | string | ❌ | `"verticalList"` | 정렬 전략: `verticalList` / `horizontalList` / `rectSorting` |
|
|
| `handle` | string | ❌ | - | 드래그 핸들 CSS 선택자 (예: `"[data-drag-handle]"`) |
|
|
| `itemVar` | string | ❌ | `"$item"` | 아이템 컨텍스트 변수명 |
|
|
| `indexVar` | string | ❌ | `"$index"` | 인덱스 컨텍스트 변수명 |
|
|
|
|
### 기본 사용 예시
|
|
|
|
```json
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"sortable": {
|
|
"source": "{{_local.form.additional_options}}",
|
|
"itemKey": "id",
|
|
"strategy": "verticalList",
|
|
"handle": "[data-drag-handle]"
|
|
},
|
|
"itemTemplate": {
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"className": "flex items-center gap-2 p-2 border rounded"
|
|
},
|
|
"children": [
|
|
{
|
|
"type": "basic",
|
|
"name": "Div",
|
|
"props": {
|
|
"data-drag-handle": true,
|
|
"className": "cursor-grab"
|
|
},
|
|
"children": [{ "type": "basic", "name": "Icon", "props": { "name": "fa-grip-vertical" } }]
|
|
},
|
|
{
|
|
"type": "basic",
|
|
"name": "Span",
|
|
"props": { "textContent": "{{$item.name}}" }
|
|
}
|
|
]
|
|
},
|
|
"actions": [
|
|
{
|
|
"event": "onSortEnd",
|
|
"handler": "setState",
|
|
"params": {
|
|
"target": "local",
|
|
"form.additional_options": "{{$sortedItems}}"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### sortable 전용 이벤트
|
|
|
|
| 이벤트 | 설명 | 컨텍스트 변수 |
|
|
| ------ | ---- | ------------- |
|
|
| `onSortEnd` | 정렬 완료 시 | `$sortedItems` (정렬된 배열), `$oldIndex`, `$newIndex` |
|
|
| `onSortStart` | 드래그 시작 시 | `$activeId` (드래그 중인 아이템 ID) |
|
|
|
|
### 드래그 핸들
|
|
|
|
`handle` 속성을 지정하면 해당 CSS 선택자를 가진 요소만 드래그 가능합니다.
|
|
`data-drag-handle` 속성이 있는 요소에 DynamicRenderer가 자동으로 드래그 리스너를 바인딩합니다.
|
|
|
|
```json
|
|
{
|
|
"sortable": {
|
|
"source": "{{_local.form.items}}",
|
|
"handle": "[data-drag-handle]"
|
|
}
|
|
}
|
|
```
|
|
|
|
핸들을 지정하지 않으면 아이템 전체가 드래그 가능합니다.
|
|
|
|
### 주의 사항
|
|
|
|
```text
|
|
sortable은 iteration보다 우선 처리됨 (같은 컴포넌트에 둘 다 있으면 sortable만 동작)
|
|
sortable 아이템 내부에서는 폼 자동 바인딩(auto-binding)이 비활성화됨
|
|
→ 인덱스 기반 경로가 정렬 후 stale 값을 참조하는 문제 방지
|
|
→ 핸들러(setState 등)에서 직접 상태를 관리해야 함
|
|
onSortEnd에서 setState로 소스 배열을 업데이트해야 정렬 결과가 반영됨
|
|
아이템에 고유한 id 필드가 필수 (itemKey로 지정)
|
|
HTML5 네이티브 D&D(dragstart/dragover/drop 이벤트)는 sortable에 사용하지 않음
|
|
→ @dnd-kit이 PointerSensor/KeyboardSensor로 자체 처리
|
|
```
|
|
|
|
---
|
|
|
|
## onSuccess/onError 후속 액션
|
|
|
|
API 호출 등의 액션 후에 후속 액션을 실행할 수 있습니다.
|
|
|
|
### 배열 지원
|
|
|
|
`onSuccess`와 `onError`는 **단일 액션 또는 배열**을 지원합니다.
|
|
|
|
```json
|
|
{
|
|
"type": "click",
|
|
"handler": "apiCall",
|
|
"target": "/api/admin/users/bulk-status",
|
|
"params": {
|
|
"method": "PATCH",
|
|
"body": {
|
|
"ids": "{{_global.selectedIds}}",
|
|
"status": "active"
|
|
}
|
|
},
|
|
"onSuccess": [
|
|
{ "handler": "closeModal" },
|
|
{ "handler": "setState", "params": { "target": "global", "selectedIds": [] } },
|
|
{ "handler": "navigate", "params": { "path": "/admin/users", "mergeQuery": true, "query": {} } },
|
|
{ "handler": "toast", "params": { "type": "success", "message": "$t:admin.users.modals.bulk_activate_success" } }
|
|
]
|
|
}
|
|
```
|
|
|
|
### onError에서 접근 가능한 데이터
|
|
|
|
| 바인딩 | 설명 |
|
|
|--------|------|
|
|
| `{{error.message}}` | 에러 메시지 (번역됨) |
|
|
| `{{error.status}}` | HTTP 상태 코드 |
|
|
| `{{error.data}}` | API 응답 전체 (success, message, errors 포함) |
|
|
| `{{error.data.errors}}` | errors 객체 (추가 에러 정보) |
|
|
|
|
---
|
|
|
|
## API 데이터 바인딩 규칙
|
|
|
|
```text
|
|
API 응답 데이터 구조를 절대 추측하지 않음
|
|
|
|
1. 새로운 API 연동 시 → 실제 API 응답 구조를 컨트롤러 코드에서 확인
|
|
2. onSuccess/onError 콜백 → 정확한 컨텍스트 변수 경로 확인 필수
|
|
3. 불확실한 경우 → 기존 레이아웃의 유사 패턴 참조
|
|
```
|
|
|
|
### 그누보드7 API 응답 표준 구조
|
|
|
|
ResponseHelper를 사용하는 모든 API는 다음 구조를 따릅니다:
|
|
|
|
**성공 응답 (success)**:
|
|
```json
|
|
{
|
|
"success": true,
|
|
"message": "번역된 메시지",
|
|
"data": { ... }
|
|
}
|
|
```
|
|
|
|
**에러 응답 (error)**:
|
|
```json
|
|
{
|
|
"success": false,
|
|
"message": "번역된 에러 메시지",
|
|
"errors": { ... }
|
|
}
|
|
```
|
|
|
|
### 콜백별 컨텍스트 변수 경로
|
|
|
|
```text
|
|
주의: onSuccess/onError 콜백에서 `$response` 사용 금지 — `response` 사용
|
|
올바른 변수명: `response` ($ 접두사 없음)
|
|
`$response`는 ActionDispatcher 컨텍스트에 존재하지 않는 변수 → undefined 반환
|
|
→ preprocessOptionalChaining이 `$response?.data?.xxx`로 변환 → undefined (에러 없이 조용히 실패)
|
|
→ fallback 값이 있으면 항상 fallback만 표시되어 버그가 은폐됨
|
|
```
|
|
|
|
| 콜백 | 변수 | 실제 경로 | 설명 |
|
|
|------|------|----------|------|
|
|
| onSuccess | `response` | - | 전체 API 응답 (권장) |
|
|
| onSuccess | `response.data` | - | 성공 시 data 필드 |
|
|
| onSuccess | `result` | - | `response`와 동일 (하위 호환성) |
|
|
| onError | `error.message` | - | 번역된 에러 메시지 |
|
|
| onError | `error.status` | - | HTTP 상태 코드 |
|
|
| onError | `error.data` | - | 전체 API 응답 |
|
|
| onError | `error.data.errors` | - | errors 객체 |
|
|
| onError | `error.data.errors.필드명` | - | 특정 에러 필드 |
|
|
|
|
### API 데이터 바인딩 체크리스트
|
|
|
|
```
|
|
□ API 응답 구조를 실제로 확인했는가? (추측 금지)
|
|
□ ResponseHelper의 success/error 반환 구조를 이해했는가?
|
|
□ 중첩된 객체 경로가 정확한가? (예: error.data.errors.field)
|
|
□ 배열 vs 객체 구분이 명확한가?
|
|
□ Optional Chaining(?.)을 적절히 사용했는가?
|
|
```
|
|
|
|
### 자주 하는 실수
|
|
|
|
```json
|
|
// ❌ 잘못된 예: 응답 구조 추측
|
|
"deactivateWarningData": "{{error.response}}"
|
|
"deactivateWarningData": "{{error.dependent_templates}}"
|
|
|
|
// ✅ 올바른 예: 실제 구조 확인 후 사용
|
|
"deactivateWarningData": "{{error.data}}"
|
|
// 모달에서: {{_global.deactivateWarningData.errors.dependent_templates}}
|
|
```
|
|
|
|
---
|
|
|
|
## 에러 핸들링 시스템 (errorHandling)
|
|
|
|
> **버전**: engine-v1.6.0+
|
|
|
|
HTTP 에러 코드별로 다른 처리 로직을 정의할 수 있습니다.
|
|
|
|
### errorHandling vs onError 역할 분리
|
|
|
|
| 속성 | 역할 | 실행 조건 |
|
|
|------|------|----------|
|
|
| `errorHandling` | 특정 에러 코드별 처리 | 해당 코드 또는 default가 정의된 경우 |
|
|
| `onError` | 범용 에러 처리 (폴백) | errorHandling에 해당 코드가 없을 때 |
|
|
|
|
### 처리 흐름
|
|
|
|
```text
|
|
API 에러 발생 (예: 403)
|
|
↓
|
|
errorHandling[403] 있음?
|
|
├── ✅ → errorHandling[403] 실행 (종료)
|
|
↓ ❌
|
|
errorHandling[default] 있음?
|
|
├── ✅ → errorHandling[default] 실행 (종료)
|
|
↓ ❌
|
|
onError 있음?
|
|
├── ✅ → onError 실행 (종료)
|
|
↓ ❌
|
|
상위 레벨 errorHandling 확인
|
|
```
|
|
|
|
### 기본 사용법
|
|
|
|
```json
|
|
{
|
|
"type": "click",
|
|
"handler": "apiCall",
|
|
"auth_required": true,
|
|
"target": "/api/admin/users/{{id}}",
|
|
"params": { "method": "DELETE" },
|
|
"errorHandling": {
|
|
"403": {
|
|
"handler": "openModal",
|
|
"target": "permission_denied_modal"
|
|
},
|
|
"404": {
|
|
"handler": "toast",
|
|
"params": { "type": "warning", "message": "$t:errors.user_not_found" }
|
|
}
|
|
},
|
|
"onError": {
|
|
"handler": "toast",
|
|
"params": { "type": "error", "message": "{{error.message}}" }
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 관련 문서
|
|
|
|
- [액션 핸들러 인덱스](actions-handlers.md)
|
|
- [네비게이션 핸들러](actions-handlers-navigation.md)
|
|
- [UI 핸들러](actions-handlers-ui.md)
|
|
- [상태 관리](state-management.md)
|
|
- [데이터 소스](data-sources.md)
|