create-mendix-widget-gleam 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/package.json +3 -2
- package/src/index.mjs +49 -45
- package/template/docs/glendix_guide.md +1365 -0
- package/template/gleam.toml +1 -0
- package/template/manifest.toml +2 -0
- package/template/src/widget/__widget_name__.gleam +4 -4
- package/template/src/widget/mendix/action.gleam +0 -41
- package/template/src/widget/mendix/big.gleam +0 -86
- package/template/src/widget/mendix/date.gleam +0 -80
- package/template/src/widget/mendix/dynamic_value.gleam +0 -30
- package/template/src/widget/mendix/editable_value.gleam +0 -73
- package/template/src/widget/mendix/file.gleam +0 -34
- package/template/src/widget/mendix/filter.gleam +0 -109
- package/template/src/widget/mendix/formatter.gleam +0 -18
- package/template/src/widget/mendix/icon.gleam +0 -34
- package/template/src/widget/mendix/list_attribute.gleam +0 -57
- package/template/src/widget/mendix/list_value.gleam +0 -106
- package/template/src/widget/mendix/reference.gleam +0 -47
- package/template/src/widget/mendix/selection.gleam +0 -31
- package/template/src/widget/mendix.gleam +0 -65
- package/template/src/widget/mendix_ffi.mjs +0 -515
- package/template/src/widget/react/event.gleam +0 -33
- package/template/src/widget/react/hook.gleam +0 -65
- package/template/src/widget/react/html.gleam +0 -156
- package/template/src/widget/react/prop.gleam +0 -106
- package/template/src/widget/react.gleam +0 -85
- package/template/src/widget/react_ffi.mjs +0 -184
|
@@ -0,0 +1,1365 @@
|
|
|
1
|
+
|
|
2
|
+
---
|
|
3
|
+
|
|
4
|
+
## 1. 시작하기
|
|
5
|
+
|
|
6
|
+
### 1.1 사전 요구사항
|
|
7
|
+
|
|
8
|
+
- **Gleam** (최신 버전) — [gleam.run](https://gleam.run)
|
|
9
|
+
- **Node.js** (v18 이상)
|
|
10
|
+
- **Mendix Studio Pro** (위젯 배포 시)
|
|
11
|
+
- Gleam의 JavaScript 타겟 빌드에 대한 기본 이해
|
|
12
|
+
|
|
13
|
+
### 1.2 프로젝트 설정
|
|
14
|
+
|
|
15
|
+
#### 1) Gleam 프로젝트 생성
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
gleam new my_widget --target javascript
|
|
19
|
+
cd my_widget
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
#### 2) glendix 의존성 추가
|
|
23
|
+
|
|
24
|
+
`gleam.toml`에 다음을 추가합니다:
|
|
25
|
+
|
|
26
|
+
```toml
|
|
27
|
+
[dependencies]
|
|
28
|
+
gleam_stdlib = ">= 0.44.0 and < 2.0.0"
|
|
29
|
+
glendix = { path = "../glendix" }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
> Hex 패키지 배포 전까지는 로컬 경로로 참조합니다.
|
|
33
|
+
|
|
34
|
+
#### 3) Peer Dependencies 설치
|
|
35
|
+
|
|
36
|
+
위젯 프로젝트의 `package.json`에 다음이 필요합니다:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"react": "^19.0.0",
|
|
42
|
+
"big.js": "^6.0.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
#### 4) 빌드 확인
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
gleam build
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### 1.3 첫 번째 위젯 만들기
|
|
58
|
+
|
|
59
|
+
`src/my_widget.gleam` 파일을 생성합니다:
|
|
60
|
+
|
|
61
|
+
```gleam
|
|
62
|
+
import glendix/mendix
|
|
63
|
+
import glendix/react.{type JsProps, type ReactElement}
|
|
64
|
+
import glendix/react/html
|
|
65
|
+
import glendix/react/prop
|
|
66
|
+
|
|
67
|
+
pub fn widget(props: JsProps) -> ReactElement {
|
|
68
|
+
let greeting = mendix.get_string_prop(props, "greetingText")
|
|
69
|
+
|
|
70
|
+
html.div(prop.new() |> prop.class("my-widget"), [
|
|
71
|
+
html.h1_([react.text(greeting)]),
|
|
72
|
+
html.p_([react.text("glendix로 만든 첫 번째 위젯입니다!")]),
|
|
73
|
+
])
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
이것이 Mendix Pluggable Widget의 전부입니다 — `fn(JsProps) -> ReactElement`.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 2. 핵심 개념
|
|
82
|
+
|
|
83
|
+
### 2.1 위젯 함수 시그니처
|
|
84
|
+
|
|
85
|
+
모든 Mendix Pluggable Widget은 하나의 함수입니다:
|
|
86
|
+
|
|
87
|
+
```gleam
|
|
88
|
+
pub fn widget(props: JsProps) -> ReactElement
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- `JsProps`: Mendix가 위젯에 전달하는 프로퍼티 객체 (opaque 타입)
|
|
92
|
+
- `ReactElement`: React가 렌더링할 수 있는 요소
|
|
93
|
+
|
|
94
|
+
### 2.2 Opaque 타입
|
|
95
|
+
|
|
96
|
+
glendix의 핵심 설계 원칙은 **opaque 타입을 통한 타입 안전성**입니다.
|
|
97
|
+
|
|
98
|
+
```gleam
|
|
99
|
+
// 이 타입들은 내부 구현이 숨겨져 있어 잘못된 접근을 컴파일 타임에 차단합니다
|
|
100
|
+
ReactElement // React 요소
|
|
101
|
+
JsProps // Mendix 프로퍼티 객체
|
|
102
|
+
Props // React props 객체
|
|
103
|
+
EditableValue // Mendix 편집 가능한 값
|
|
104
|
+
ActionValue // Mendix 액션
|
|
105
|
+
ListValue // Mendix 리스트 데이터
|
|
106
|
+
// ... 등등
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
각 opaque 타입은 반드시 해당 모듈이 제공하는 접근자 함수를 통해서만 사용할 수 있습니다. 이를 통해 JS 런타임 에러를 Gleam 컴파일 타임 에러로 전환합니다.
|
|
110
|
+
|
|
111
|
+
### 2.3 undefined ↔ Option 자동 변환
|
|
112
|
+
|
|
113
|
+
FFI 경계에서 JavaScript의 `undefined`/`null`은 자동으로 변환됩니다:
|
|
114
|
+
|
|
115
|
+
| JavaScript | Gleam |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `undefined` / `null` | `None` |
|
|
118
|
+
| 값 존재 | `Some(value)` |
|
|
119
|
+
|
|
120
|
+
```gleam
|
|
121
|
+
// Mendix props에서 값 가져오기
|
|
122
|
+
case mendix.get_prop(props, "myAttr") {
|
|
123
|
+
Some(attr) -> // 값이 설정되어 있음
|
|
124
|
+
None -> // 값이 없음 (undefined)
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 2.4 파이프라인 API
|
|
129
|
+
|
|
130
|
+
Props는 Gleam의 파이프 연산자(`|>`)를 활용한 빌더 패턴으로 구성합니다:
|
|
131
|
+
|
|
132
|
+
```gleam
|
|
133
|
+
let my_props =
|
|
134
|
+
prop.new()
|
|
135
|
+
|> prop.class("card card-primary")
|
|
136
|
+
|> prop.string("id", "main-card")
|
|
137
|
+
|> prop.bool("hidden", False)
|
|
138
|
+
|> prop.on_click(fn(_event) { Nil })
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
이 패턴은 **가독성**과 **합성 가능성**을 모두 제공합니다.
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 3. React 바인딩
|
|
146
|
+
|
|
147
|
+
### 3.1 엘리먼트 생성
|
|
148
|
+
|
|
149
|
+
`glendix/react` 모듈은 React 엘리먼트를 생성하는 핵심 함수들을 제공합니다.
|
|
150
|
+
|
|
151
|
+
#### 기본 엘리먼트
|
|
152
|
+
|
|
153
|
+
```gleam
|
|
154
|
+
import glendix/react
|
|
155
|
+
import glendix/react/prop
|
|
156
|
+
|
|
157
|
+
// Props가 있는 엘리먼트
|
|
158
|
+
react.el("div", prop.new() |> prop.class("container"), [
|
|
159
|
+
react.text("Hello"),
|
|
160
|
+
])
|
|
161
|
+
|
|
162
|
+
// Props 없이 간단하게
|
|
163
|
+
react.el_("div", [
|
|
164
|
+
react.text("Hello"),
|
|
165
|
+
])
|
|
166
|
+
|
|
167
|
+
// Self-closing 엘리먼트 (input, img, br 등)
|
|
168
|
+
react.void("input", prop.new() |> prop.string("type", "text"))
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
#### 텍스트 노드
|
|
172
|
+
|
|
173
|
+
```gleam
|
|
174
|
+
react.text("안녕하세요")
|
|
175
|
+
react.text("Count: " <> int.to_string(count))
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
#### Fragment
|
|
179
|
+
|
|
180
|
+
```gleam
|
|
181
|
+
// 기본 Fragment
|
|
182
|
+
react.fragment([
|
|
183
|
+
html.h1_([react.text("제목")]),
|
|
184
|
+
html.p_([react.text("내용")]),
|
|
185
|
+
])
|
|
186
|
+
|
|
187
|
+
// 키가 있는 Fragment (리스트 렌더링에서 사용)
|
|
188
|
+
react.keyed_fragment("unique-key", [
|
|
189
|
+
html.li_([react.text("아이템")]),
|
|
190
|
+
])
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
#### 아무것도 렌더링하지 않기
|
|
194
|
+
|
|
195
|
+
```gleam
|
|
196
|
+
react.none() // React null 반환
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
#### 외부 React 컴포넌트 사용
|
|
200
|
+
|
|
201
|
+
```gleam
|
|
202
|
+
// 다른 React 컴포넌트를 합성할 때
|
|
203
|
+
react.component(my_component, prop.new() |> prop.string("title", "Hello"), [
|
|
204
|
+
// children
|
|
205
|
+
])
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### 3.2 Props 빌더
|
|
209
|
+
|
|
210
|
+
`glendix/react/prop` 모듈은 타입별 props 설정 함수를 제공합니다.
|
|
211
|
+
|
|
212
|
+
#### 기본 타입 설정
|
|
213
|
+
|
|
214
|
+
```gleam
|
|
215
|
+
import glendix/react/prop
|
|
216
|
+
|
|
217
|
+
let props =
|
|
218
|
+
prop.new()
|
|
219
|
+
|> prop.string("placeholder", "입력하세요") // String
|
|
220
|
+
|> prop.int("tabIndex", 0) // Int
|
|
221
|
+
|> prop.float("opacity", 0.5) // Float
|
|
222
|
+
|> prop.bool("disabled", True) // Bool
|
|
223
|
+
|> prop.any("data", some_value) // 임의 타입
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
#### 자주 쓰는 속성
|
|
227
|
+
|
|
228
|
+
```gleam
|
|
229
|
+
let props =
|
|
230
|
+
prop.new()
|
|
231
|
+
|> prop.class("btn btn-primary") // className 설정
|
|
232
|
+
|> prop.classes(["btn", "btn-large"]) // 여러 클래스 공백으로 결합
|
|
233
|
+
|> prop.key("item-1") // React key (리스트 렌더링)
|
|
234
|
+
|> prop.ref(my_ref) // React ref
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
#### 이벤트 핸들러
|
|
238
|
+
|
|
239
|
+
```gleam
|
|
240
|
+
let props =
|
|
241
|
+
prop.new()
|
|
242
|
+
|> prop.on_click(fn(event) { handle_click(event) })
|
|
243
|
+
|> prop.on_change(fn(event) { handle_change(event) })
|
|
244
|
+
|> prop.on_submit(fn(event) { handle_submit(event) })
|
|
245
|
+
|> prop.on_key_down(fn(event) { handle_key(event) })
|
|
246
|
+
|> prop.on_focus(fn(event) { handle_focus(event) })
|
|
247
|
+
|> prop.on_blur(fn(event) { handle_blur(event) })
|
|
248
|
+
|
|
249
|
+
// 커스텀 이벤트
|
|
250
|
+
|> prop.on("onMouseEnter", fn(event) { handle_mouse(event) })
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### 3.3 HTML 태그 함수
|
|
254
|
+
|
|
255
|
+
`glendix/react/html` 모듈은 30개 이상의 HTML 태그를 위한 편의 함수를 제공합니다. 순수 Gleam으로 구현되어 FFI가 없습니다.
|
|
256
|
+
|
|
257
|
+
```gleam
|
|
258
|
+
import glendix/react/html
|
|
259
|
+
import glendix/react/prop
|
|
260
|
+
|
|
261
|
+
// Props가 있는 버전
|
|
262
|
+
html.div(prop.new() |> prop.class("container"), children)
|
|
263
|
+
html.button(prop.new() |> prop.on_click(handler), children)
|
|
264
|
+
html.input(prop.new() |> prop.string("type", "text")) // void 엘리먼트
|
|
265
|
+
|
|
266
|
+
// Props 없는 버전 (언더스코어 접미사)
|
|
267
|
+
html.div_(children)
|
|
268
|
+
html.span_([react.text("텍스트")])
|
|
269
|
+
html.p_([react.text("문단")])
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
#### 사용 가능한 태그 목록
|
|
273
|
+
|
|
274
|
+
| 카테고리 | 태그 |
|
|
275
|
+
|---|---|
|
|
276
|
+
| **컨테이너** | `div`, `span`, `section`, `main`, `header`, `footer`, `nav`, `aside`, `article` |
|
|
277
|
+
| **텍스트** | `p`, `h1`, `h2`, `h3`, `h4`, `h5`, `h6` |
|
|
278
|
+
| **리스트** | `ul`, `ol`, `li` |
|
|
279
|
+
| **폼** | `form`, `button`, `label`, `select`, `option`, `textarea` |
|
|
280
|
+
| **입력** | `input` (void 엘리먼트) |
|
|
281
|
+
| **테이블** | `table`, `thead`, `tbody`, `tr`, `td`, `th` |
|
|
282
|
+
| **링크/미디어** | `a`, `img` (void), `br` (void), `hr` (void) |
|
|
283
|
+
|
|
284
|
+
### 3.4 React Hooks
|
|
285
|
+
|
|
286
|
+
`glendix/react/hook` 모듈은 주요 React Hooks를 제공합니다.
|
|
287
|
+
|
|
288
|
+
> Gleam의 튜플 `#(a, b)`은 JavaScript 배열 `[a, b]`과 동일하므로 React Hooks의 반환값과 직접 호환됩니다.
|
|
289
|
+
|
|
290
|
+
#### useState — 상태 관리
|
|
291
|
+
|
|
292
|
+
```gleam
|
|
293
|
+
import glendix/react/hook
|
|
294
|
+
|
|
295
|
+
pub fn counter(_props) -> ReactElement {
|
|
296
|
+
let #(count, set_count) = hook.use_state(0)
|
|
297
|
+
let #(name, set_name) = hook.use_state("")
|
|
298
|
+
|
|
299
|
+
html.div_([
|
|
300
|
+
html.p_([react.text("Count: " <> int.to_string(count))]),
|
|
301
|
+
html.button(
|
|
302
|
+
prop.new() |> prop.on_click(fn(_) { set_count(count + 1) }),
|
|
303
|
+
[react.text("+1")],
|
|
304
|
+
),
|
|
305
|
+
])
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
#### useEffect — 사이드 이펙트
|
|
310
|
+
|
|
311
|
+
```gleam
|
|
312
|
+
// 의존성 배열 지정
|
|
313
|
+
hook.use_effect(fn() {
|
|
314
|
+
// count가 변경될 때마다 실행
|
|
315
|
+
Nil
|
|
316
|
+
}, #(count)) // 의존성을 튜플로 전달
|
|
317
|
+
|
|
318
|
+
// 마운트 시 한 번만 실행
|
|
319
|
+
hook.use_effect_once(fn() {
|
|
320
|
+
Nil
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
// 매 렌더링마다 실행
|
|
324
|
+
hook.use_effect_always(fn() {
|
|
325
|
+
Nil
|
|
326
|
+
})
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
#### useEffect + 클린업
|
|
330
|
+
|
|
331
|
+
```gleam
|
|
332
|
+
// 클린업 함수가 있는 effect
|
|
333
|
+
hook.use_effect_once_cleanup(fn() {
|
|
334
|
+
// 마운트 시 실행
|
|
335
|
+
let timer_id = set_interval(update, 1000)
|
|
336
|
+
|
|
337
|
+
// 클린업 함수 반환 (언마운트 시 실행)
|
|
338
|
+
fn() { clear_interval(timer_id) }
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
hook.use_effect_cleanup(fn() {
|
|
342
|
+
// effect 실행
|
|
343
|
+
fn() { /* 클린업 */ }
|
|
344
|
+
}, #(dependency))
|
|
345
|
+
|
|
346
|
+
hook.use_effect_always_cleanup(fn() {
|
|
347
|
+
fn() { /* 매 렌더 후 클린업 */ }
|
|
348
|
+
})
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
#### useMemo — 메모이제이션
|
|
352
|
+
|
|
353
|
+
```gleam
|
|
354
|
+
// 값이 비용이 클 때 메모이제이션
|
|
355
|
+
let expensive_result = hook.use_memo(fn() {
|
|
356
|
+
compute_expensive_value(data)
|
|
357
|
+
}, #(data))
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
#### useCallback — 콜백 메모이제이션
|
|
361
|
+
|
|
362
|
+
```gleam
|
|
363
|
+
// 콜백 함수 메모이제이션 (자식 컴포넌트에 전달할 때 유용)
|
|
364
|
+
let handle_click = hook.use_callback(fn(event) {
|
|
365
|
+
set_count(count + 1)
|
|
366
|
+
}, #(count))
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
#### useRef — 참조
|
|
370
|
+
|
|
371
|
+
```gleam
|
|
372
|
+
let input_ref = hook.use_ref(Nil)
|
|
373
|
+
|
|
374
|
+
// ref 값 읽기
|
|
375
|
+
let current = hook.get_ref(input_ref)
|
|
376
|
+
|
|
377
|
+
// ref 값 쓰기
|
|
378
|
+
hook.set_ref(input_ref, new_value)
|
|
379
|
+
|
|
380
|
+
// DOM 요소에 연결
|
|
381
|
+
html.input(prop.new() |> prop.ref(input_ref))
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### 3.5 이벤트 처리
|
|
385
|
+
|
|
386
|
+
`glendix/react/event` 모듈은 이벤트 타입과 유틸리티 함수를 제공합니다.
|
|
387
|
+
|
|
388
|
+
#### 이벤트 타입
|
|
389
|
+
|
|
390
|
+
| 타입 | 용도 |
|
|
391
|
+
|---|---|
|
|
392
|
+
| `Event` | 기본 이벤트 |
|
|
393
|
+
| `MouseEvent` | 클릭, 마우스 이벤트 |
|
|
394
|
+
| `ChangeEvent` | input 변경 이벤트 |
|
|
395
|
+
| `KeyboardEvent` | 키보드 이벤트 |
|
|
396
|
+
| `FormEvent` | 폼 제출 이벤트 |
|
|
397
|
+
| `FocusEvent` | 포커스/블러 이벤트 |
|
|
398
|
+
|
|
399
|
+
#### 이벤트 접근자
|
|
400
|
+
|
|
401
|
+
```gleam
|
|
402
|
+
import glendix/react/event
|
|
403
|
+
|
|
404
|
+
// input 값 가져오기
|
|
405
|
+
prop.on_change(fn(e) {
|
|
406
|
+
let value = event.target_value(e)
|
|
407
|
+
set_name(value)
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
// 키보드 이벤트
|
|
411
|
+
prop.on_key_down(fn(e) {
|
|
412
|
+
case event.key(e) {
|
|
413
|
+
"Enter" -> submit()
|
|
414
|
+
"Escape" -> cancel()
|
|
415
|
+
_ -> Nil
|
|
416
|
+
}
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
// 기본 동작 방지
|
|
420
|
+
prop.on_submit(fn(e) {
|
|
421
|
+
event.prevent_default(e)
|
|
422
|
+
handle_submit()
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
// 이벤트 전파 중지
|
|
426
|
+
prop.on_click(fn(e) {
|
|
427
|
+
event.stop_propagation(e)
|
|
428
|
+
handle_click()
|
|
429
|
+
})
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### 3.6 조건부 렌더링
|
|
433
|
+
|
|
434
|
+
```gleam
|
|
435
|
+
import glendix/react
|
|
436
|
+
|
|
437
|
+
// Bool 기반 — 조건이 True일 때만 렌더링
|
|
438
|
+
react.when(is_logged_in, fn() {
|
|
439
|
+
html.div_([react.text("환영합니다!")])
|
|
440
|
+
})
|
|
441
|
+
|
|
442
|
+
// Option 기반 — Some일 때만 렌더링
|
|
443
|
+
react.when_some(maybe_user, fn(user) {
|
|
444
|
+
html.span_([react.text(user.name)])
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
// case 표현식으로 복잡한 조건 처리
|
|
448
|
+
case status {
|
|
449
|
+
Loading -> html.div_([react.text("로딩 중...")])
|
|
450
|
+
Available -> html.div_([react.text("완료")])
|
|
451
|
+
Unavailable -> react.none()
|
|
452
|
+
}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
### 3.7 리스트 렌더링
|
|
456
|
+
|
|
457
|
+
```gleam
|
|
458
|
+
import gleam/list
|
|
459
|
+
|
|
460
|
+
// 리스트를 map하여 엘리먼트 생성
|
|
461
|
+
let items = ["사과", "바나나", "체리"]
|
|
462
|
+
|
|
463
|
+
html.ul_(
|
|
464
|
+
list.map(items, fn(item) {
|
|
465
|
+
html.li(prop.new() |> prop.key(item), [
|
|
466
|
+
react.text(item),
|
|
467
|
+
])
|
|
468
|
+
}),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
// 인덱스가 필요한 경우
|
|
472
|
+
list.index_map(items, fn(item, idx) {
|
|
473
|
+
html.li(prop.new() |> prop.key(int.to_string(idx)), [
|
|
474
|
+
react.text(item),
|
|
475
|
+
])
|
|
476
|
+
})
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
> 리스트 렌더링 시 항상 `prop.key()`를 설정하세요. React의 reconciliation에 필요합니다.
|
|
480
|
+
|
|
481
|
+
### 3.8 스타일 지정
|
|
482
|
+
|
|
483
|
+
```gleam
|
|
484
|
+
import glendix/react/prop
|
|
485
|
+
|
|
486
|
+
// 인라인 스타일 적용
|
|
487
|
+
let my_style =
|
|
488
|
+
prop.new_style()
|
|
489
|
+
|> prop.set("backgroundColor", "#f0f0f0")
|
|
490
|
+
|> prop.set("padding", "16px")
|
|
491
|
+
|> prop.set("borderRadius", "8px")
|
|
492
|
+
|
|
493
|
+
html.div(prop.new() |> prop.style(my_style), children)
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
> CSS 속성명은 JavaScript camelCase 표기법을 사용합니다 (예: `backgroundColor`, `fontSize`).
|
|
497
|
+
|
|
498
|
+
---
|
|
499
|
+
|
|
500
|
+
## 4. Mendix 바인딩
|
|
501
|
+
|
|
502
|
+
### 4.1 Props 접근
|
|
503
|
+
|
|
504
|
+
`glendix/mendix` 모듈로 Mendix가 위젯에 전달하는 props에 접근합니다.
|
|
505
|
+
|
|
506
|
+
```gleam
|
|
507
|
+
import glendix/mendix
|
|
508
|
+
|
|
509
|
+
// Option 반환 (undefined면 None)
|
|
510
|
+
case mendix.get_prop(props, "myAttribute") {
|
|
511
|
+
Some(attr) -> use_attribute(attr)
|
|
512
|
+
None -> react.none()
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// 항상 존재하는 prop (undefined일 수 없는 경우)
|
|
516
|
+
let value = mendix.get_prop_required(props, "alwaysPresent")
|
|
517
|
+
|
|
518
|
+
// String prop (없으면 빈 문자열 반환)
|
|
519
|
+
let text = mendix.get_string_prop(props, "caption")
|
|
520
|
+
|
|
521
|
+
// prop 존재 여부 확인
|
|
522
|
+
let has_action = mendix.has_prop(props, "onClick")
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
#### ValueStatus 확인
|
|
526
|
+
|
|
527
|
+
Mendix의 모든 동적 값은 상태(status)를 가집니다:
|
|
528
|
+
|
|
529
|
+
```gleam
|
|
530
|
+
import glendix/mendix.{Available, Loading, Unavailable}
|
|
531
|
+
|
|
532
|
+
case mendix.get_status(some_value) {
|
|
533
|
+
Available -> // 값 사용 가능
|
|
534
|
+
Loading -> // 로딩 중
|
|
535
|
+
Unavailable -> // 사용 불가
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### 4.2 EditableValue — 편집 가능한 값
|
|
540
|
+
|
|
541
|
+
`glendix/mendix/editable_value`는 텍스트, 숫자, 날짜 등 편집 가능한 Mendix 속성을 다룹니다.
|
|
542
|
+
|
|
543
|
+
```gleam
|
|
544
|
+
import gleam/option.{None, Some}
|
|
545
|
+
import glendix/mendix
|
|
546
|
+
import glendix/mendix/editable_value as ev
|
|
547
|
+
|
|
548
|
+
pub fn text_input(props: JsProps) -> ReactElement {
|
|
549
|
+
case mendix.get_prop(props, "textAttribute") {
|
|
550
|
+
Some(attr) -> render_input(attr)
|
|
551
|
+
None -> react.none()
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
fn render_input(attr) -> ReactElement {
|
|
556
|
+
// 값 읽기
|
|
557
|
+
let current_value = ev.value(attr) // Option(a)
|
|
558
|
+
let display = ev.display_value(attr) // String (포맷된 표시값)
|
|
559
|
+
let is_editable = ev.is_editable(attr) // Bool (Available && !read_only)
|
|
560
|
+
|
|
561
|
+
// 유효성 검사 메시지 확인
|
|
562
|
+
let validation_msg = ev.validation(attr) // Option(String)
|
|
563
|
+
|
|
564
|
+
html.div_([
|
|
565
|
+
html.input(
|
|
566
|
+
prop.new()
|
|
567
|
+
|> prop.string("value", display)
|
|
568
|
+
|> prop.bool("readOnly", !is_editable)
|
|
569
|
+
|> prop.on_change(fn(e) {
|
|
570
|
+
// 텍스트로 값 설정 (Mendix가 파싱)
|
|
571
|
+
ev.set_text_value(attr, event.target_value(e))
|
|
572
|
+
}),
|
|
573
|
+
),
|
|
574
|
+
// 유효성 검사 에러 표시
|
|
575
|
+
react.when_some(validation_msg, fn(msg) {
|
|
576
|
+
html.span(prop.new() |> prop.class("text-danger"), [
|
|
577
|
+
react.text(msg),
|
|
578
|
+
])
|
|
579
|
+
}),
|
|
580
|
+
])
|
|
581
|
+
}
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
#### 값 설정 방법
|
|
585
|
+
|
|
586
|
+
```gleam
|
|
587
|
+
// Option으로 직접 설정 (타입이 맞아야 함)
|
|
588
|
+
ev.set_value(attr, Some(new_value)) // 값 설정
|
|
589
|
+
ev.set_value(attr, None) // 값 비우기
|
|
590
|
+
|
|
591
|
+
// 텍스트로 설정 (Mendix가 자동 파싱 — 숫자, 날짜 등에 유용)
|
|
592
|
+
ev.set_text_value(attr, "2024-01-15")
|
|
593
|
+
|
|
594
|
+
// 커스텀 유효성 검사 함수 설정
|
|
595
|
+
ev.set_validator(attr, Some(fn(value) {
|
|
596
|
+
case value {
|
|
597
|
+
Some(v) if v == "" -> Some("값을 입력하세요")
|
|
598
|
+
_ -> None // 유효함
|
|
599
|
+
}
|
|
600
|
+
}))
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
#### 가능한 값 목록 (Enum, Boolean 등)
|
|
604
|
+
|
|
605
|
+
```gleam
|
|
606
|
+
case ev.universe(attr) {
|
|
607
|
+
Some(options) ->
|
|
608
|
+
// options: List(a) — 선택 가능한 모든 값
|
|
609
|
+
html.select_(
|
|
610
|
+
list.map(options, fn(opt) {
|
|
611
|
+
html.option_([react.text(string.inspect(opt))])
|
|
612
|
+
}),
|
|
613
|
+
)
|
|
614
|
+
None -> react.none()
|
|
615
|
+
}
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
### 4.3 ActionValue — 액션 실행
|
|
619
|
+
|
|
620
|
+
`glendix/mendix/action`으로 Mendix 마이크로플로우/나노플로우를 실행합니다.
|
|
621
|
+
|
|
622
|
+
```gleam
|
|
623
|
+
import glendix/mendix
|
|
624
|
+
import glendix/mendix/action
|
|
625
|
+
|
|
626
|
+
pub fn action_button(props: JsProps) -> ReactElement {
|
|
627
|
+
let on_click = mendix.get_prop(props, "onClick") // Option(ActionValue)
|
|
628
|
+
|
|
629
|
+
html.button(
|
|
630
|
+
prop.new()
|
|
631
|
+
|> prop.class("btn")
|
|
632
|
+
|> prop.bool("disabled", case on_click {
|
|
633
|
+
Some(a) -> !action.can_execute(a)
|
|
634
|
+
None -> True
|
|
635
|
+
})
|
|
636
|
+
|> prop.on_click(fn(_) {
|
|
637
|
+
// Option(ActionValue) 안전하게 실행
|
|
638
|
+
action.execute_action(on_click)
|
|
639
|
+
}),
|
|
640
|
+
[react.text("실행")],
|
|
641
|
+
)
|
|
642
|
+
}
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
#### 액션 실행 방법
|
|
646
|
+
|
|
647
|
+
```gleam
|
|
648
|
+
// 직접 실행 (can_execute 확인 없이)
|
|
649
|
+
action.execute(my_action)
|
|
650
|
+
|
|
651
|
+
// can_execute가 True일 때만 실행
|
|
652
|
+
action.execute_if_can(my_action)
|
|
653
|
+
|
|
654
|
+
// Option(ActionValue)에서 안전하게 실행
|
|
655
|
+
action.execute_action(maybe_action) // None이면 아무것도 안 함
|
|
656
|
+
|
|
657
|
+
// 실행 상태 확인
|
|
658
|
+
let can = action.can_execute(my_action) // Bool
|
|
659
|
+
let running = action.is_executing(my_action) // Bool
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
### 4.4 DynamicValue — 읽기 전용 표현식
|
|
663
|
+
|
|
664
|
+
`glendix/mendix/dynamic_value`는 Mendix 표현식(Expression) 속성을 다룹니다.
|
|
665
|
+
|
|
666
|
+
```gleam
|
|
667
|
+
import glendix/mendix/dynamic_value as dv
|
|
668
|
+
|
|
669
|
+
pub fn display_expression(props: JsProps) -> ReactElement {
|
|
670
|
+
case mendix.get_prop(props, "expression") {
|
|
671
|
+
Some(expr) ->
|
|
672
|
+
case dv.value(expr) {
|
|
673
|
+
Some(text) -> html.span_([react.text(text)])
|
|
674
|
+
None -> react.none()
|
|
675
|
+
}
|
|
676
|
+
None -> react.none()
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// 상태 확인
|
|
681
|
+
let status = dv.status(expr)
|
|
682
|
+
let ready = dv.is_available(expr)
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
### 4.5 ListValue — 리스트 데이터
|
|
686
|
+
|
|
687
|
+
`glendix/mendix/list_value`는 Mendix 데이터 소스 리스트를 다룹니다.
|
|
688
|
+
|
|
689
|
+
```gleam
|
|
690
|
+
import glendix/mendix
|
|
691
|
+
import glendix/mendix/list_value as lv
|
|
692
|
+
|
|
693
|
+
pub fn data_list(props: JsProps) -> ReactElement {
|
|
694
|
+
case mendix.get_prop(props, "dataSource") {
|
|
695
|
+
Some(list_val) -> render_list(list_val, props)
|
|
696
|
+
None -> react.none()
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
fn render_list(list_val, props) -> ReactElement {
|
|
701
|
+
case lv.items(list_val) {
|
|
702
|
+
Some(items) ->
|
|
703
|
+
html.ul_(
|
|
704
|
+
list.map(items, fn(item) {
|
|
705
|
+
let id = mendix.object_id(item)
|
|
706
|
+
html.li(prop.new() |> prop.key(id), [
|
|
707
|
+
react.text("Item: " <> id),
|
|
708
|
+
])
|
|
709
|
+
}),
|
|
710
|
+
)
|
|
711
|
+
None ->
|
|
712
|
+
html.div_([react.text("로딩 중...")])
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
#### 페이지네이션
|
|
718
|
+
|
|
719
|
+
```gleam
|
|
720
|
+
// 현재 페이지 정보
|
|
721
|
+
let offset = lv.offset(list_val) // 현재 오프셋
|
|
722
|
+
let limit = lv.limit(list_val) // 페이지 크기
|
|
723
|
+
let has_more = lv.has_more_items(list_val) // Option(Bool)
|
|
724
|
+
|
|
725
|
+
// 페이지 이동
|
|
726
|
+
lv.set_offset(list_val, offset + limit) // 다음 페이지
|
|
727
|
+
lv.set_limit(list_val, 20) // 페이지 크기 변경
|
|
728
|
+
|
|
729
|
+
// 전체 개수 요청 (성능 고려)
|
|
730
|
+
lv.request_total_count(list_val, True)
|
|
731
|
+
let total = lv.total_count(list_val) // Option(Int)
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
#### 정렬
|
|
735
|
+
|
|
736
|
+
```gleam
|
|
737
|
+
import glendix/mendix/list_value as lv
|
|
738
|
+
|
|
739
|
+
// 정렬 적용
|
|
740
|
+
lv.set_sort_order(list_val, [
|
|
741
|
+
lv.sort("Name", lv.Asc),
|
|
742
|
+
lv.sort("CreatedDate", lv.Desc),
|
|
743
|
+
])
|
|
744
|
+
|
|
745
|
+
// 현재 정렬 확인
|
|
746
|
+
let current_sort = lv.sort_order(list_val)
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
#### 데이터 갱신
|
|
750
|
+
|
|
751
|
+
```gleam
|
|
752
|
+
lv.reload(list_val) // 데이터 다시 로드
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
### 4.6 ListAttribute — 리스트 아이템 접근
|
|
756
|
+
|
|
757
|
+
`glendix/mendix/list_attribute`는 리스트의 각 아이템에서 속성, 액션, 표현식, 위젯을 추출합니다.
|
|
758
|
+
|
|
759
|
+
```gleam
|
|
760
|
+
import glendix/mendix/list_attribute as la
|
|
761
|
+
|
|
762
|
+
pub fn render_table(props: JsProps) -> ReactElement {
|
|
763
|
+
let list_val = mendix.get_prop_required(props, "dataSource")
|
|
764
|
+
let name_attr = mendix.get_prop_required(props, "nameAttr")
|
|
765
|
+
let edit_action = mendix.get_prop(props, "onEdit")
|
|
766
|
+
|
|
767
|
+
case lv.items(list_val) {
|
|
768
|
+
Some(items) ->
|
|
769
|
+
html.table_(
|
|
770
|
+
[html.tbody_(
|
|
771
|
+
list.map(items, fn(item) {
|
|
772
|
+
let id = mendix.object_id(item)
|
|
773
|
+
|
|
774
|
+
// 아이템에서 속성값 추출
|
|
775
|
+
let name_ev = la.get_attribute(name_attr, item)
|
|
776
|
+
let display = ev.display_value(name_ev)
|
|
777
|
+
|
|
778
|
+
// 아이템에서 액션 추출
|
|
779
|
+
let action_opt = case edit_action {
|
|
780
|
+
Some(act) -> la.get_action(act, item)
|
|
781
|
+
None -> None
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
html.tr(prop.new() |> prop.key(id), [
|
|
785
|
+
html.td_([react.text(display)]),
|
|
786
|
+
html.td_([
|
|
787
|
+
html.button(
|
|
788
|
+
prop.new()
|
|
789
|
+
|> prop.on_click(fn(_) {
|
|
790
|
+
action.execute_action(action_opt)
|
|
791
|
+
}),
|
|
792
|
+
[react.text("편집")],
|
|
793
|
+
),
|
|
794
|
+
]),
|
|
795
|
+
])
|
|
796
|
+
}),
|
|
797
|
+
)],
|
|
798
|
+
)
|
|
799
|
+
None -> html.div_([react.text("로딩 중...")])
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
#### ListAttributeValue 메타데이터
|
|
805
|
+
|
|
806
|
+
```gleam
|
|
807
|
+
// 속성 정보 확인
|
|
808
|
+
let id = la.attr_id(name_attr) // String - 속성 ID
|
|
809
|
+
let sortable = la.attr_sortable(name_attr) // Bool
|
|
810
|
+
let filterable = la.attr_filterable(name_attr) // Bool
|
|
811
|
+
let type_name = la.attr_type(name_attr) // "String", "Integer" 등
|
|
812
|
+
let formatter = la.attr_formatter(name_attr) // ValueFormatter
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
#### 위젯 렌더링
|
|
816
|
+
|
|
817
|
+
```gleam
|
|
818
|
+
// 리스트 아이템별 위젯 (Mendix Studio에서 구성)
|
|
819
|
+
let content_widget = mendix.get_prop_required(props, "content")
|
|
820
|
+
|
|
821
|
+
list.map(items, fn(item) {
|
|
822
|
+
let widget_element = la.get_widget(content_widget, item)
|
|
823
|
+
html.div(prop.new() |> prop.key(mendix.object_id(item)), [
|
|
824
|
+
widget_element, // ReactElement로 직접 사용
|
|
825
|
+
])
|
|
826
|
+
})
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
### 4.7 Selection — 선택
|
|
830
|
+
|
|
831
|
+
`glendix/mendix/selection`으로 단일/다중 선택을 관리합니다.
|
|
832
|
+
|
|
833
|
+
#### 단일 선택
|
|
834
|
+
|
|
835
|
+
```gleam
|
|
836
|
+
import glendix/mendix/selection
|
|
837
|
+
|
|
838
|
+
// 현재 선택된 항목
|
|
839
|
+
let selected = selection.selection(single_sel) // Option(ObjectItem)
|
|
840
|
+
|
|
841
|
+
// 선택 설정/해제
|
|
842
|
+
selection.set_selection(single_sel, Some(item)) // 선택
|
|
843
|
+
selection.set_selection(single_sel, None) // 선택 해제
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
#### 다중 선택
|
|
847
|
+
|
|
848
|
+
```gleam
|
|
849
|
+
// 선택된 항목들
|
|
850
|
+
let selected_items = selection.selections(multi_sel) // List(ObjectItem)
|
|
851
|
+
|
|
852
|
+
// 선택 설정
|
|
853
|
+
selection.set_selections(multi_sel, [item1, item2])
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
### 4.8 Reference — 연관 관계
|
|
857
|
+
|
|
858
|
+
`glendix/mendix/reference`로 Mendix 연관 관계(Association)를 다룹니다.
|
|
859
|
+
|
|
860
|
+
```gleam
|
|
861
|
+
import glendix/mendix/reference as ref
|
|
862
|
+
|
|
863
|
+
// 단일 참조 (1:1, N:1)
|
|
864
|
+
let referenced = ref.value(my_ref) // Option(a)
|
|
865
|
+
let is_readonly = ref.read_only(my_ref) // Bool
|
|
866
|
+
let error = ref.validation(my_ref) // Option(String)
|
|
867
|
+
|
|
868
|
+
ref.set_value(my_ref, Some(new_item)) // 참조 설정
|
|
869
|
+
ref.set_value(my_ref, None) // 참조 해제
|
|
870
|
+
|
|
871
|
+
// 다중 참조 (M:N)
|
|
872
|
+
let items = ref.multi_value(my_ref_set) // Option(List(a))
|
|
873
|
+
ref.set_multi_value(my_ref_set, Some([item1, item2]))
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
### 4.9 Filter — 필터 조건 빌더
|
|
877
|
+
|
|
878
|
+
`glendix/mendix/filter`로 ListValue에 적용할 필터 조건을 프로그래밍 방식으로 구성합니다.
|
|
879
|
+
|
|
880
|
+
```gleam
|
|
881
|
+
import glendix/mendix/filter
|
|
882
|
+
import glendix/mendix/list_value as lv
|
|
883
|
+
|
|
884
|
+
// 단순 비교
|
|
885
|
+
let name_filter =
|
|
886
|
+
filter.contains(
|
|
887
|
+
filter.attribute("Name"),
|
|
888
|
+
filter.literal("검색어"),
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
// 복합 조건 (AND)
|
|
892
|
+
let complex_filter =
|
|
893
|
+
filter.and_([
|
|
894
|
+
filter.equals(
|
|
895
|
+
filter.attribute("Status"),
|
|
896
|
+
filter.literal("Active"),
|
|
897
|
+
),
|
|
898
|
+
filter.greater_than(
|
|
899
|
+
filter.attribute("Amount"),
|
|
900
|
+
filter.literal(100),
|
|
901
|
+
),
|
|
902
|
+
])
|
|
903
|
+
|
|
904
|
+
// 필터 적용
|
|
905
|
+
lv.set_filter(list_val, Some(complex_filter))
|
|
906
|
+
|
|
907
|
+
// 필터 해제
|
|
908
|
+
lv.set_filter(list_val, None)
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
#### 사용 가능한 비교 연산자
|
|
912
|
+
|
|
913
|
+
| 함수 | 설명 |
|
|
914
|
+
|---|---|
|
|
915
|
+
| `equals(a, b)` | 같음 |
|
|
916
|
+
| `not_equal(a, b)` | 다름 |
|
|
917
|
+
| `greater_than(a, b)` | 초과 |
|
|
918
|
+
| `greater_than_or_equal(a, b)` | 이상 |
|
|
919
|
+
| `less_than(a, b)` | 미만 |
|
|
920
|
+
| `less_than_or_equal(a, b)` | 이하 |
|
|
921
|
+
| `contains(a, b)` | 포함 (문자열) |
|
|
922
|
+
| `starts_with(a, b)` | 시작 (문자열) |
|
|
923
|
+
| `ends_with(a, b)` | 끝 (문자열) |
|
|
924
|
+
|
|
925
|
+
#### 날짜 비교
|
|
926
|
+
|
|
927
|
+
```gleam
|
|
928
|
+
filter.day_equals(filter.attribute("Birthday"), filter.literal(date))
|
|
929
|
+
filter.day_greater_than(filter.attribute("CreatedDate"), filter.literal(start_date))
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
#### 논리 조합
|
|
933
|
+
|
|
934
|
+
```gleam
|
|
935
|
+
filter.and_([condition1, condition2]) // AND
|
|
936
|
+
filter.or_([condition1, condition2]) // OR
|
|
937
|
+
filter.not_(condition) // NOT
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
#### 표현식 타입
|
|
941
|
+
|
|
942
|
+
```gleam
|
|
943
|
+
filter.attribute("AttrName") // 속성 참조
|
|
944
|
+
filter.association("AssocName") // 연관 관계 참조
|
|
945
|
+
filter.literal(value) // 상수 값
|
|
946
|
+
filter.empty() // 빈 값 (null 비교용)
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
### 4.10 날짜와 숫자
|
|
950
|
+
|
|
951
|
+
#### JsDate — 날짜 처리
|
|
952
|
+
|
|
953
|
+
`glendix/mendix/date`는 JavaScript Date를 Gleam에서 안전하게 다룹니다.
|
|
954
|
+
|
|
955
|
+
> 핵심: Gleam에서 월(month)은 **1-based** (1~12), JavaScript에서는 0-based (0~11). glendix가 자동 변환합니다.
|
|
956
|
+
|
|
957
|
+
```gleam
|
|
958
|
+
import glendix/mendix/date
|
|
959
|
+
|
|
960
|
+
// 생성
|
|
961
|
+
let now = date.now()
|
|
962
|
+
let parsed = date.from_iso("2024-03-15T10:30:00Z")
|
|
963
|
+
let custom = date.create(2024, 3, 15, 10, 30, 0, 0) // 월: 1-12!
|
|
964
|
+
let from_ts = date.from_timestamp(1710500000000)
|
|
965
|
+
|
|
966
|
+
// 읽기
|
|
967
|
+
let year = date.year(now) // 예: 2024
|
|
968
|
+
let month = date.month(now) // 1~12 (자동 변환!)
|
|
969
|
+
let day = date.day(now) // 1~31
|
|
970
|
+
let hours = date.hours(now) // 0~23
|
|
971
|
+
let dow = date.day_of_week(now) // 0=일요일
|
|
972
|
+
|
|
973
|
+
// 변환
|
|
974
|
+
let iso = date.to_iso(now) // "2024-03-15T10:30:00.000Z"
|
|
975
|
+
let ts = date.to_timestamp(now) // Unix 밀리초
|
|
976
|
+
let str = date.to_string(now) // 사람이 읽을 수 있는 형식
|
|
977
|
+
let input_val = date.to_input_value(now) // "2024-03-15" (input[type="date"]용)
|
|
978
|
+
|
|
979
|
+
// input[type="date"]에서 파싱
|
|
980
|
+
let maybe_date = date.from_input_value("2024-03-15") // Option(JsDate)
|
|
981
|
+
```
|
|
982
|
+
|
|
983
|
+
#### Big — 고정밀 십진수
|
|
984
|
+
|
|
985
|
+
`glendix/mendix/big`는 Big.js를 래핑하여 Mendix의 Decimal 타입을 정밀하게 처리합니다.
|
|
986
|
+
|
|
987
|
+
```gleam
|
|
988
|
+
import glendix/mendix/big
|
|
989
|
+
import gleam/order
|
|
990
|
+
|
|
991
|
+
// 생성
|
|
992
|
+
let a = big.from_string("123.456")
|
|
993
|
+
let b = big.from_int(100)
|
|
994
|
+
let c = big.from_float(99.99)
|
|
995
|
+
|
|
996
|
+
// 연산
|
|
997
|
+
let sum = big.add(a, b) // 223.456
|
|
998
|
+
let diff = big.subtract(a, b) // 23.456
|
|
999
|
+
let prod = big.multiply(a, b) // 12345.6
|
|
1000
|
+
let quot = big.divide(a, b) // 1.23456
|
|
1001
|
+
let abs = big.absolute(diff) // 양수화
|
|
1002
|
+
let neg = big.negate(a) // -123.456
|
|
1003
|
+
|
|
1004
|
+
// 비교
|
|
1005
|
+
let cmp = big.compare(a, b) // order.Gt
|
|
1006
|
+
let eq = big.equal(a, b) // False
|
|
1007
|
+
|
|
1008
|
+
// 변환
|
|
1009
|
+
let str = big.to_string(sum) // "223.456"
|
|
1010
|
+
let f = big.to_float(sum) // 223.456
|
|
1011
|
+
let i = big.to_int(sum) // 223 (소수점 버림)
|
|
1012
|
+
let fixed = big.to_fixed(sum, 2) // "223.46"
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
### 4.11 파일, 아이콘, 포맷터
|
|
1016
|
+
|
|
1017
|
+
#### FileValue / WebImage
|
|
1018
|
+
|
|
1019
|
+
```gleam
|
|
1020
|
+
import glendix/mendix/file
|
|
1021
|
+
|
|
1022
|
+
// FileValue
|
|
1023
|
+
let uri = file.uri(file_val) // String - 파일 URI
|
|
1024
|
+
let name = file.name(file_val) // Option(String) - 파일명
|
|
1025
|
+
|
|
1026
|
+
// WebImage (FileValue + alt 텍스트)
|
|
1027
|
+
let src = file.image_uri(img) // String
|
|
1028
|
+
let alt = file.alt_text(img) // Option(String)
|
|
1029
|
+
|
|
1030
|
+
html.img(
|
|
1031
|
+
prop.new()
|
|
1032
|
+
|> prop.string("src", src)
|
|
1033
|
+
|> prop.string("alt", option.unwrap(alt, "")),
|
|
1034
|
+
)
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
#### WebIcon
|
|
1038
|
+
|
|
1039
|
+
```gleam
|
|
1040
|
+
import glendix/mendix/icon
|
|
1041
|
+
|
|
1042
|
+
case icon.icon_type(my_icon) {
|
|
1043
|
+
icon.Glyph ->
|
|
1044
|
+
html.span(prop.new() |> prop.class(icon.icon_class(my_icon)), [])
|
|
1045
|
+
icon.Image ->
|
|
1046
|
+
html.img(prop.new() |> prop.string("src", icon.icon_url(my_icon)))
|
|
1047
|
+
icon.IconFont ->
|
|
1048
|
+
html.span(prop.new() |> prop.class(icon.icon_class(my_icon)), [])
|
|
1049
|
+
}
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
#### ValueFormatter
|
|
1053
|
+
|
|
1054
|
+
```gleam
|
|
1055
|
+
import glendix/mendix/formatter
|
|
1056
|
+
|
|
1057
|
+
// 값을 문자열로 포맷
|
|
1058
|
+
let display = formatter.format(fmt, Some(value)) // String
|
|
1059
|
+
let empty = formatter.format(fmt, None) // ""
|
|
1060
|
+
|
|
1061
|
+
// 텍스트를 값으로 파싱
|
|
1062
|
+
case formatter.parse(fmt, "123.45") {
|
|
1063
|
+
Ok(Some(value)) -> // 파싱 성공
|
|
1064
|
+
Ok(None) -> // 빈 값
|
|
1065
|
+
Error(Nil) -> // 파싱 실패
|
|
1066
|
+
}
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
---
|
|
1070
|
+
|
|
1071
|
+
## 5. 실전 패턴
|
|
1072
|
+
|
|
1073
|
+
### 5.1 폼 입력 위젯
|
|
1074
|
+
|
|
1075
|
+
```gleam
|
|
1076
|
+
import gleam/option.{None, Some}
|
|
1077
|
+
import glendix/mendix
|
|
1078
|
+
import glendix/mendix/action
|
|
1079
|
+
import glendix/mendix/editable_value as ev
|
|
1080
|
+
import glendix/react.{type JsProps, type ReactElement}
|
|
1081
|
+
import glendix/react/event
|
|
1082
|
+
import glendix/react/hook
|
|
1083
|
+
import glendix/react/html
|
|
1084
|
+
import glendix/react/prop
|
|
1085
|
+
|
|
1086
|
+
pub fn text_input_widget(props: JsProps) -> ReactElement {
|
|
1087
|
+
let attr = mendix.get_prop(props, "textAttribute")
|
|
1088
|
+
let on_enter = mendix.get_prop(props, "onEnterAction")
|
|
1089
|
+
let placeholder = mendix.get_string_prop(props, "placeholder")
|
|
1090
|
+
|
|
1091
|
+
case attr {
|
|
1092
|
+
Some(text_attr) -> {
|
|
1093
|
+
let display = ev.display_value(text_attr)
|
|
1094
|
+
let editable = ev.is_editable(text_attr)
|
|
1095
|
+
let validation = ev.validation(text_attr)
|
|
1096
|
+
|
|
1097
|
+
html.div(prop.new() |> prop.class("form-group"), [
|
|
1098
|
+
html.input(
|
|
1099
|
+
prop.new()
|
|
1100
|
+
|> prop.class("form-control")
|
|
1101
|
+
|> prop.string("value", display)
|
|
1102
|
+
|> prop.string("placeholder", placeholder)
|
|
1103
|
+
|> prop.bool("readOnly", !editable)
|
|
1104
|
+
|> prop.on_change(fn(e) {
|
|
1105
|
+
ev.set_text_value(text_attr, event.target_value(e))
|
|
1106
|
+
})
|
|
1107
|
+
|> prop.on_key_down(fn(e) {
|
|
1108
|
+
case event.key(e) {
|
|
1109
|
+
"Enter" -> action.execute_action(on_enter)
|
|
1110
|
+
_ -> Nil
|
|
1111
|
+
}
|
|
1112
|
+
}),
|
|
1113
|
+
),
|
|
1114
|
+
// 유효성 검사 메시지
|
|
1115
|
+
react.when_some(validation, fn(msg) {
|
|
1116
|
+
html.div(prop.new() |> prop.class("alert alert-danger"), [
|
|
1117
|
+
react.text(msg),
|
|
1118
|
+
])
|
|
1119
|
+
}),
|
|
1120
|
+
])
|
|
1121
|
+
}
|
|
1122
|
+
None -> react.none()
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
```
|
|
1126
|
+
|
|
1127
|
+
### 5.2 데이터 테이블 위젯
|
|
1128
|
+
|
|
1129
|
+
```gleam
|
|
1130
|
+
import gleam/int
|
|
1131
|
+
import gleam/list
|
|
1132
|
+
import gleam/option.{None, Some}
|
|
1133
|
+
import glendix/mendix
|
|
1134
|
+
import glendix/mendix/editable_value as ev
|
|
1135
|
+
import glendix/mendix/list_attribute as la
|
|
1136
|
+
import glendix/mendix/list_value as lv
|
|
1137
|
+
import glendix/react.{type JsProps, type ReactElement}
|
|
1138
|
+
import glendix/react/html
|
|
1139
|
+
import glendix/react/prop
|
|
1140
|
+
|
|
1141
|
+
pub fn data_table(props: JsProps) -> ReactElement {
|
|
1142
|
+
let ds = mendix.get_prop_required(props, "dataSource")
|
|
1143
|
+
let col_name = mendix.get_prop_required(props, "nameColumn")
|
|
1144
|
+
let col_status = mendix.get_prop_required(props, "statusColumn")
|
|
1145
|
+
|
|
1146
|
+
html.div(prop.new() |> prop.class("table-responsive"), [
|
|
1147
|
+
html.table(prop.new() |> prop.class("table table-striped"), [
|
|
1148
|
+
// 헤더
|
|
1149
|
+
html.thead_([
|
|
1150
|
+
html.tr_([
|
|
1151
|
+
html.th_([react.text("이름")]),
|
|
1152
|
+
html.th_([react.text("상태")]),
|
|
1153
|
+
]),
|
|
1154
|
+
]),
|
|
1155
|
+
// 바디
|
|
1156
|
+
html.tbody_(
|
|
1157
|
+
case lv.items(ds) {
|
|
1158
|
+
Some(items) ->
|
|
1159
|
+
list.map(items, fn(item) {
|
|
1160
|
+
let id = mendix.object_id(item)
|
|
1161
|
+
let name = ev.display_value(la.get_attribute(col_name, item))
|
|
1162
|
+
let status = ev.display_value(la.get_attribute(col_status, item))
|
|
1163
|
+
|
|
1164
|
+
html.tr(prop.new() |> prop.key(id), [
|
|
1165
|
+
html.td_([react.text(name)]),
|
|
1166
|
+
html.td_([react.text(status)]),
|
|
1167
|
+
])
|
|
1168
|
+
})
|
|
1169
|
+
None -> [
|
|
1170
|
+
html.tr_([
|
|
1171
|
+
html.td(
|
|
1172
|
+
prop.new() |> prop.string("colSpan", "2"),
|
|
1173
|
+
[react.text("로딩 중...")],
|
|
1174
|
+
),
|
|
1175
|
+
]),
|
|
1176
|
+
]
|
|
1177
|
+
},
|
|
1178
|
+
),
|
|
1179
|
+
]),
|
|
1180
|
+
// 페이지네이션
|
|
1181
|
+
render_pagination(ds),
|
|
1182
|
+
])
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
fn render_pagination(ds) -> ReactElement {
|
|
1186
|
+
let offset = lv.offset(ds)
|
|
1187
|
+
let limit = lv.limit(ds)
|
|
1188
|
+
let has_more = lv.has_more_items(ds)
|
|
1189
|
+
|
|
1190
|
+
html.div(prop.new() |> prop.class("pagination"), [
|
|
1191
|
+
html.button(
|
|
1192
|
+
prop.new()
|
|
1193
|
+
|> prop.bool("disabled", offset == 0)
|
|
1194
|
+
|> prop.on_click(fn(_) {
|
|
1195
|
+
lv.set_offset(ds, int.max(0, offset - limit))
|
|
1196
|
+
}),
|
|
1197
|
+
[react.text("이전")],
|
|
1198
|
+
),
|
|
1199
|
+
html.button(
|
|
1200
|
+
prop.new()
|
|
1201
|
+
|> prop.bool("disabled", has_more == Some(False))
|
|
1202
|
+
|> prop.on_click(fn(_) {
|
|
1203
|
+
lv.set_offset(ds, offset + limit)
|
|
1204
|
+
}),
|
|
1205
|
+
[react.text("다음")],
|
|
1206
|
+
),
|
|
1207
|
+
])
|
|
1208
|
+
}
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
### 5.3 검색 가능한 리스트
|
|
1212
|
+
|
|
1213
|
+
```gleam
|
|
1214
|
+
import gleam/option.{None, Some}
|
|
1215
|
+
import glendix/mendix
|
|
1216
|
+
import glendix/mendix/filter
|
|
1217
|
+
import glendix/mendix/list_value as lv
|
|
1218
|
+
import glendix/react.{type JsProps, type ReactElement}
|
|
1219
|
+
import glendix/react/event
|
|
1220
|
+
import glendix/react/hook
|
|
1221
|
+
import glendix/react/html
|
|
1222
|
+
import glendix/react/prop
|
|
1223
|
+
|
|
1224
|
+
pub fn searchable_list(props: JsProps) -> ReactElement {
|
|
1225
|
+
let ds = mendix.get_prop_required(props, "dataSource")
|
|
1226
|
+
let search_attr = mendix.get_string_prop(props, "searchAttribute")
|
|
1227
|
+
let #(query, set_query) = hook.use_state("")
|
|
1228
|
+
|
|
1229
|
+
// 검색어 변경 시 필터 적용
|
|
1230
|
+
hook.use_effect(fn() {
|
|
1231
|
+
case query {
|
|
1232
|
+
"" -> lv.set_filter(ds, None)
|
|
1233
|
+
q ->
|
|
1234
|
+
lv.set_filter(ds, Some(
|
|
1235
|
+
filter.contains(
|
|
1236
|
+
filter.attribute(search_attr),
|
|
1237
|
+
filter.literal(q),
|
|
1238
|
+
),
|
|
1239
|
+
))
|
|
1240
|
+
}
|
|
1241
|
+
Nil
|
|
1242
|
+
}, #(query))
|
|
1243
|
+
|
|
1244
|
+
html.div_([
|
|
1245
|
+
// 검색 입력
|
|
1246
|
+
html.input(
|
|
1247
|
+
prop.new()
|
|
1248
|
+
|> prop.class("form-control")
|
|
1249
|
+
|> prop.string("type", "search")
|
|
1250
|
+
|> prop.string("placeholder", "검색...")
|
|
1251
|
+
|> prop.string("value", query)
|
|
1252
|
+
|> prop.on_change(fn(e) { set_query(event.target_value(e)) }),
|
|
1253
|
+
),
|
|
1254
|
+
// 결과 리스트 렌더링
|
|
1255
|
+
render_results(ds),
|
|
1256
|
+
])
|
|
1257
|
+
}
|
|
1258
|
+
```
|
|
1259
|
+
|
|
1260
|
+
### 5.4 컴포넌트 합성
|
|
1261
|
+
|
|
1262
|
+
Gleam 함수를 컴포넌트처럼 활용하여 UI를 분리합니다:
|
|
1263
|
+
|
|
1264
|
+
```gleam
|
|
1265
|
+
import glendix/react.{type ReactElement}
|
|
1266
|
+
import glendix/react/html
|
|
1267
|
+
import glendix/react/prop
|
|
1268
|
+
|
|
1269
|
+
// 재사용 가능한 카드 컴포넌트
|
|
1270
|
+
fn card(title: String, children: List(ReactElement)) -> ReactElement {
|
|
1271
|
+
html.div(prop.new() |> prop.class("card"), [
|
|
1272
|
+
html.div(prop.new() |> prop.class("card-header"), [
|
|
1273
|
+
html.h3_([react.text(title)]),
|
|
1274
|
+
]),
|
|
1275
|
+
html.div(prop.new() |> prop.class("card-body"), children),
|
|
1276
|
+
])
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// 재사용 가능한 빈 상태 컴포넌트
|
|
1280
|
+
fn empty_state(message: String) -> ReactElement {
|
|
1281
|
+
html.div(prop.new() |> prop.class("empty-state"), [
|
|
1282
|
+
html.p_([react.text(message)]),
|
|
1283
|
+
])
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// 조합하여 사용
|
|
1287
|
+
pub fn dashboard(props) -> ReactElement {
|
|
1288
|
+
html.div(prop.new() |> prop.class("dashboard"), [
|
|
1289
|
+
card("사용자 목록", [
|
|
1290
|
+
// 리스트 내용...
|
|
1291
|
+
]),
|
|
1292
|
+
card("최근 활동", [
|
|
1293
|
+
empty_state("아직 활동이 없습니다."),
|
|
1294
|
+
]),
|
|
1295
|
+
])
|
|
1296
|
+
}
|
|
1297
|
+
```
|
|
1298
|
+
|
|
1299
|
+
---
|
|
1300
|
+
|
|
1301
|
+
## 6. 트러블슈팅
|
|
1302
|
+
|
|
1303
|
+
### 빌드 에러
|
|
1304
|
+
|
|
1305
|
+
| 문제 | 원인 | 해결 |
|
|
1306
|
+
|---|---|---|
|
|
1307
|
+
| `gleam build` 실패: glendix를 찾을 수 없음 | `gleam.toml`의 경로가 잘못됨 | `path = "../glendix"` 경로 확인 |
|
|
1308
|
+
| `react is not defined` | peer dependency 미설치 | `npm install react@^19.0.0` |
|
|
1309
|
+
| `Big is not a constructor` | big.js 미설치 | `npm install big.js@^6.0.0` |
|
|
1310
|
+
|
|
1311
|
+
### 런타임 에러
|
|
1312
|
+
|
|
1313
|
+
| 문제 | 원인 | 해결 |
|
|
1314
|
+
|---|---|---|
|
|
1315
|
+
| `Cannot read property of undefined` | 존재하지 않는 prop 접근 | `get_prop` (Option) 대신 `get_prop_required` 사용 시 prop 이름 확인 |
|
|
1316
|
+
| `set_value` 호출 시 에러 | read_only 상태에서 값 설정 | `ev.is_editable(attr)` 확인 후 설정 |
|
|
1317
|
+
| Hook 순서 에러 | 조건부로 Hook 호출 | Hook은 항상 동일한 순서로 호출해야 함 (React Rules of Hooks) |
|
|
1318
|
+
|
|
1319
|
+
### 일반적인 실수
|
|
1320
|
+
|
|
1321
|
+
**1. Hook을 조건부로 호출하지 마세요:**
|
|
1322
|
+
|
|
1323
|
+
```gleam
|
|
1324
|
+
// 잘못된 예
|
|
1325
|
+
pub fn widget(props) {
|
|
1326
|
+
case mendix.get_prop(props, "attr") {
|
|
1327
|
+
Some(attr) -> {
|
|
1328
|
+
let #(count, set_count) = hook.use_state(0) // 조건 안에서 Hook!
|
|
1329
|
+
// ...
|
|
1330
|
+
}
|
|
1331
|
+
None -> react.none()
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// 올바른 예
|
|
1336
|
+
pub fn widget(props) {
|
|
1337
|
+
let #(count, set_count) = hook.use_state(0) // 항상 최상위에서 호출
|
|
1338
|
+
|
|
1339
|
+
case mendix.get_prop(props, "attr") {
|
|
1340
|
+
Some(attr) -> // count 사용...
|
|
1341
|
+
None -> react.none()
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
```
|
|
1345
|
+
|
|
1346
|
+
**2. 리스트 렌더링에서 key를 빠뜨리지 마세요:**
|
|
1347
|
+
|
|
1348
|
+
```gleam
|
|
1349
|
+
// key가 있어야 React가 효율적으로 업데이트합니다
|
|
1350
|
+
list.map(items, fn(item) {
|
|
1351
|
+
html.div(prop.new() |> prop.key(mendix.object_id(item)), [
|
|
1352
|
+
// ...
|
|
1353
|
+
])
|
|
1354
|
+
})
|
|
1355
|
+
```
|
|
1356
|
+
|
|
1357
|
+
**3. 월(month) 변환을 직접 하지 마세요:**
|
|
1358
|
+
|
|
1359
|
+
```gleam
|
|
1360
|
+
// glendix/mendix/date가 자동으로 1-based ↔ 0-based 변환합니다
|
|
1361
|
+
let month = date.month(my_date) // 1~12 (Gleam 기준, 변환 불필요)
|
|
1362
|
+
```
|
|
1363
|
+
|
|
1364
|
+
---
|
|
1365
|
+
|