create-mendix-widget-gleam 2.0.7 → 2.0.8

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.
@@ -1,2215 +1,2295 @@
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
- gleam run -m glendix/install
49
- ```
50
-
51
- > `glendix/install`은 패키지 매니저를 자동 감지하여 의존성을 설치하고, `bindings.json`이 있으면 외부 React 컴포넌트 바인딩을, `widgets/` 디렉토리에 `.mpk` 파일이 있으면 위젯 바인딩도 자동 생성합니다.
52
-
53
- #### 4) 빌드 확인
54
-
55
- ```bash
56
- gleam build
57
- ```
58
-
59
- ### 1.3 첫 번째 위젯 만들기
60
-
61
- `src/my_widget.gleam` 파일을 생성합니다:
62
-
63
- ```gleam
64
- import glendix/mendix
65
- import glendix/react.{type JsProps, type ReactElement}
66
- import glendix/react/attribute
67
- import glendix/react/html
68
-
69
- pub fn widget(props: JsProps) -> ReactElement {
70
- let greeting = mendix.get_string_prop(props, "greetingText")
71
-
72
- html.div([attribute.class("my-widget")], [
73
- html.h1([attribute.class("title")], [react.text(greeting)]),
74
- html.p_([react.text("glendix로 만든 첫 번째 위젯입니다!")]),
75
- ])
76
- }
77
- ```
78
-
79
- 이것이 Mendix Pluggable Widget의 전부입니다 — `fn(JsProps) -> ReactElement`.
80
-
81
- ---
82
-
83
- ## 2. 핵심 개념
84
-
85
- ### 2.1 위젯 함수 시그니처
86
-
87
- 모든 Mendix Pluggable Widget은 하나의 함수입니다:
88
-
89
- ```gleam
90
- pub fn widget(props: JsProps) -> ReactElement
91
- ```
92
-
93
- - `JsProps`: Mendix가 위젯에 전달하는 프로퍼티 객체 (opaque 타입)
94
- - `ReactElement`: React가 렌더링할 수 있는 요소
95
-
96
- ### 2.2 Opaque 타입
97
-
98
- glendix의 핵심 설계 원칙은 **opaque 타입을 통한 타입 안전성**입니다.
99
-
100
- ```gleam
101
- // 이 타입들은 내부 구현이 숨겨져 있어 잘못된 접근을 컴파일 타임에 차단합니다
102
- ReactElement // React 요소
103
- JsProps // Mendix 프로퍼티 객체
104
- Attribute // HTML/React 속성
105
- EditableValue // Mendix 편집 가능한 값
106
- ActionValue // Mendix 액션
107
- ListValue // Mendix 리스트 데이터
108
- // ... 등등
109
- ```
110
-
111
- 각 opaque 타입은 반드시 해당 모듈이 제공하는 접근자 함수를 통해서만 사용할 수 있습니다. 이를 통해 JS 런타임 에러를 Gleam 컴파일 타임 에러로 전환합니다.
112
-
113
- ### 2.3 undefined ↔ Option 자동 변환
114
-
115
- FFI 경계에서 JavaScript의 `undefined`/`null`은 자동으로 변환됩니다:
116
-
117
- | JavaScript | Gleam |
118
- |---|---|
119
- | `undefined` / `null` | `None` |
120
- | 값 존재 | `Some(value)` |
121
-
122
- ```gleam
123
- // Mendix props에서 값 가져오기
124
- case mendix.get_prop(props, "myAttr") {
125
- Some(attr) -> // 값이 설정되어 있음
126
- None -> // 값이 없음 (undefined)
127
- }
128
- ```
129
-
130
- ### 2.4 Attribute 리스트 API
131
-
132
- HTML 속성은 `[attribute.xxx(), event.xxx()]` 선언적 리스트 패턴으로 구성합니다:
133
-
134
- ```gleam
135
- import glendix/react/attribute
136
- import glendix/react/event
137
-
138
- html.button(
139
- [
140
- attribute.class("btn btn-primary"),
141
- attribute.type_("submit"),
142
- attribute.disabled(False),
143
- event.on_click(fn(_event) { Nil }),
144
- ],
145
- [react.text("Submit")],
146
- )
147
- ```
148
-
149
- 조건부 속성은 `attribute.none()`으로 처리합니다:
150
-
151
- ```gleam
152
- html.input([
153
- attribute.class("input"),
154
- case is_error {
155
- True -> attribute.class("input-error")
156
- False -> attribute.none()
157
- },
158
- ])
159
- ```
160
-
161
- 여러 `attribute.class()` 호출 시 자동으로 공백 구분 병합됩니다.
162
-
163
- ---
164
-
165
- ## 3. React 바인딩
166
-
167
- ### 3.1 엘리먼트 생성
168
-
169
- `glendix/react` 모듈은 React 엘리먼트를 생성하는 핵심 함수들을 제공합니다.
170
-
171
- #### 기본 엘리먼트
172
-
173
- ```gleam
174
- import glendix/react
175
- import glendix/react/attribute
176
-
177
- // Attribute 리스트가 있는 엘리먼트
178
- react.element("div", [attribute.class("container")], [
179
- react.text("Hello"),
180
- ])
181
-
182
- // 속성 없이 간단하게
183
- react.element_("div", [
184
- react.text("Hello"),
185
- ])
186
-
187
- // Self-closing 엘리먼트 (input, img, br 등)
188
- react.void_element("input", [attribute.type_("text")])
189
- ```
190
-
191
- #### 텍스트 노드
192
-
193
- ```gleam
194
- react.text("안녕하세요")
195
- react.text("Count: " <> int.to_string(count))
196
- ```
197
-
198
- #### Fragment
199
-
200
- ```gleam
201
- // 기본 Fragment
202
- react.fragment([
203
- html.h1([attribute.class("title")], [react.text("제목")]),
204
- html.p_([react.text("내용")]),
205
- ])
206
-
207
- // 키가 있는 Fragment (리스트 렌더링에서 사용)
208
- react.keyed_fragment("unique-key", [
209
- html.li_([react.text("아이템")]),
210
- ])
211
- ```
212
-
213
- #### 아무것도 렌더링하지 않기
214
-
215
- ```gleam
216
- react.none() // React null 반환
217
- ```
218
-
219
- #### 외부 React 컴포넌트 사용
220
-
221
- `glendix/binding` 모듈로 외부 React 라이브러리를 **`.mjs` 없이** 사용합니다.
222
-
223
- **1단계: `bindings.json` 작성** (프로젝트 루트)
224
-
225
- ```json
226
- {
227
- "recharts": {
228
- "components": ["PieChart", "Pie", "Cell", "LineChart", "Line",
229
- "XAxis", "YAxis", "CartesianGrid", "Tooltip", "Legend",
230
- "ResponsiveContainer"]
231
- }
232
- }
233
- ```
234
-
235
- **2단계: 패키지 설치** — `bindings.json`에 등록한 패키지는 위젯 프로젝트의 `node_modules`에 설치되어 있어야 합니다. 생성된 `binding_ffi.mjs`가 해당 패키지를 직접 import하므로, Rollup이 번들링할 때 resolve할 수 있어야 합니다.
236
-
237
- ```bash
238
- # 사용 중인 패키지 매니저에 맞게 설치
239
- npm install recharts
240
- # 또는
241
- pnpm add recharts
242
- # 또는
243
- yarn add recharts
244
- # 또는
245
- bun add recharts
246
- ```
247
-
248
- **3단계: `gleam run -m glendix/install`** 실행 (바인딩 자동 생성)
249
-
250
- **4단계: 순수 Gleam 래퍼 모듈 작성** (편의용, 선택사항)
251
-
252
- html.gleam과 동일한 호출 패턴으로 작성하면 일관된 API를 제공할 수 있습니다:
253
-
254
- ```gleam
255
- // src/chart/recharts.gleam — 순수 Gleam, FFI 없음!
256
- import glendix/binding
257
- import glendix/react.{type ReactElement}
258
- import glendix/react/attribute.{type Attribute}
259
-
260
- fn m() { binding.module("recharts") }
261
-
262
- // attrs + children 컴포넌트 (html.div 패턴)
263
- pub fn pie_chart(attrs: List(Attribute), children: List(ReactElement)) -> ReactElement {
264
- react.component_el(binding.resolve(m(), "PieChart"), attrs, children)
265
- }
266
-
267
- pub fn pie(attrs: List(Attribute), children: List(ReactElement)) -> ReactElement {
268
- react.component_el(binding.resolve(m(), "Pie"), attrs, children)
269
- }
270
-
271
- pub fn responsive_container(attrs: List(Attribute), children: List(ReactElement)) -> ReactElement {
272
- react.component_el(binding.resolve(m(), "ResponsiveContainer"), attrs, children)
273
- }
274
-
275
- // children 없는 컴포넌트 (html.input 패턴)
276
- pub fn cell(attrs: List(Attribute)) -> ReactElement {
277
- react.void_component_el(binding.resolve(m(), "Cell"), attrs)
278
- }
279
-
280
- pub fn tooltip(attrs: List(Attribute)) -> ReactElement {
281
- react.void_component_el(binding.resolve(m(), "Tooltip"), attrs)
282
- }
283
-
284
- pub fn legend(attrs: List(Attribute)) -> ReactElement {
285
- react.void_component_el(binding.resolve(m(), "Legend"), attrs)
286
- }
287
- ```
288
-
289
- **5단계: 위젯에서 사용**
290
-
291
- html.gleam과 동일한 호출 구조입니다:
292
-
293
- ```gleam
294
- import chart/recharts
295
- import glendix/react
296
- import glendix/react/attribute
297
-
298
- pub fn my_pie_chart(data) -> react.ReactElement {
299
- recharts.responsive_container(
300
- [attribute.attribute("width", 400), attribute.attribute("height", 300)],
301
- [
302
- recharts.pie_chart([], [
303
- recharts.pie(
304
- [attribute.attribute("data", data), attribute.attribute("dataKey", "value")],
305
- [],
306
- ),
307
- recharts.tooltip([]),
308
- recharts.legend([]),
309
- ]),
310
- ],
311
- )
312
- }
313
- ```
314
-
315
- 래퍼 모듈 없이 직접 사용하는 것도 가능합니다:
316
-
317
- ```gleam
318
- import glendix/binding
319
-
320
- let rc = binding.module("recharts")
321
- react.component_el(binding.resolve(rc, "PieChart"), attrs, children)
322
- react.void_component_el(binding.resolve(rc, "Tooltip"), attrs)
323
- ```
324
-
325
- #### .mpk 위젯 컴포넌트 사용
326
-
327
- `glendix/widget` 모듈로 `widgets/` 디렉토리의 `.mpk` 파일(Mendix 위젯 빌드 결과물)을 React 컴포넌트로 사용합니다. 다른 위젯 안에서 기존 Mendix 위젯을 렌더링할 때 유용합니다.
328
-
329
- **1단계: `.mpk` 파일 배치**
330
-
331
- 프로젝트 루트에 `widgets/` 디렉토리를 만들고 `.mpk` 파일을 복사합니다:
332
-
333
- ```
334
- my_widget/
335
- ├── widgets/
336
- │ ├── Switch.mpk
337
- │ └── Badge.mpk
338
- ├── src/
339
- ├── gleam.toml
340
- └── ...
341
- ```
342
-
343
- **2단계: `gleam run -m glendix/install`** 실행 (위젯 바인딩 자동 생성)
344
-
345
- install 시 두 가지가 자동 수행됩니다:
346
- - `.mpk` 내부의 `.mjs`와 `.css`가 추출되고, `widget_ffi.mjs`가 생성됩니다
347
- - `.mpk` XML의 `<property>` 정의가 부모 위젯 XML(`src/{WidgetName}.xml`)에 `<propertyGroup caption="{위젯명}">` 으로 자동 주입됩니다 (동일 caption이 이미 있으면 건너뜀)
348
-
349
- 예를 들어 `Switch.mpk`를 설치하면, 부모 위젯 XML에 다음이 자동 추가됩니다:
350
-
351
- ```xml
352
- <propertyGroup caption="Switch">
353
- <property key="booleanAttribute" type="attribute">
354
- <caption>Boolean attribute</caption>
355
- <description>Attribute to toggle</description>
356
- <attributeTypes>
357
- <attributeType name="Boolean" />
358
- </attributeTypes>
359
- </property>
360
- <property key="action" type="action" required="false">
361
- <caption>On change</caption>
362
- <description>Action to be performed when the switch is toggled</description>
363
- </property>
364
- </propertyGroup>
365
- ```
366
-
367
- **3단계: Gleam 코드에서 사용**
368
-
369
- ```gleam
370
- import glendix/mendix
371
- import glendix/widget
372
- import glendix/react
373
- import glendix/react/attribute
374
-
375
- // props에서 자동 주입된 속성을 읽어 위젯에 전달
376
- let boolean_attr = mendix.get_prop_required(props, "booleanAttribute")
377
- let action = mendix.get_prop_required(props, "action")
378
-
379
- // widgets/Switch.mpk의 Switch 컴포넌트 사용
380
- let switch_comp = widget.component("Switch")
381
- react.component_el(switch_comp, [
382
- attribute.attribute("booleanAttribute", boolean_attr),
383
- attribute.attribute("action", action),
384
- ], [])
385
- ```
386
-
387
- 위젯의 Props는 기존 `attribute.attribute(key, value)` 범용 함수로 전달합니다. 위젯 이름은 `.mpk` 내부 XML의 `<name>` 태그 값(PascalCase)을, property key는 `.mpk` XML의 원본 key를 그대로 사용합니다.
388
-
389
- > `binding` 모듈과 달리 `widget` 모듈은 1 mpk = 1 컴포넌트이므로 `module` + `resolve` 2단계 없이 `component("Name")` 한 번에 가져옵니다.
390
-
391
- ### 3.2 HTML 속성
392
-
393
- `glendix/react/attribute` 모듈은 90+ HTML 속성 함수를 제공합니다.
394
-
395
- #### 범용 속성
396
-
397
- ```gleam
398
- import glendix/react/attribute
399
-
400
- // escape hatch — 임의의 속성 설정
401
- attribute.attribute("data-custom", "value")
402
-
403
- // aria-* 속성
404
- attribute.aria("label", "닫기 버튼")
405
-
406
- // data-* 속성
407
- attribute.data("testid", "my-element")
408
- ```
409
-
410
- #### 자주 쓰는 속성
411
-
412
- ```gleam
413
- attribute.class("btn btn-primary") // className (자동 병합)
414
- attribute.classes(["btn", "large"]) // 여러 클래스 공백 결합
415
- attribute.id("main-card")
416
- attribute.key("item-1") // React key
417
- attribute.ref(my_ref) // React ref
418
- attribute.ref_(fn(el) { Nil }) // callback ref
419
- ```
420
-
421
- #### 폼 속성
422
-
423
- ```gleam
424
- attribute.type_("text")
425
- attribute.value("입력값")
426
- attribute.placeholder("입력하세요")
427
- attribute.name("username")
428
- attribute.disabled(True)
429
- attribute.checked(True)
430
- attribute.readonly(True)
431
- attribute.required(True)
432
- attribute.max_length(100)
433
- attribute.min_length(3)
434
- attribute.pattern("[0-9]+")
435
- attribute.autocomplete("email")
436
- attribute.autofocus(True)
437
- ```
438
-
439
- #### 인라인 스타일
440
-
441
- ```gleam
442
- // CSS 속성명 자동 camelCase 변환
443
- attribute.style([
444
- #("background-color", "#f0f0f0"),
445
- #("padding", "16px"),
446
- #("border-radius", "8px"),
447
- ])
448
- ```
449
-
450
- #### 미디어/리소스 속성
451
-
452
- ```gleam
453
- attribute.src("image.png")
454
- attribute.alt("설명")
455
- attribute.loading("lazy") // 지연 로딩
456
- attribute.fetch_priority("high") // 로딩 우선순위
457
- attribute.cross_origin("anonymous")
458
- attribute.srcset("img-2x.png 2x")
459
- attribute.sizes("(max-width: 600px) 100vw")
460
- ```
461
-
462
- #### 모바일/국제화 속성 (Round 3)
463
-
464
- ```gleam
465
- attribute.input_mode("numeric") // 가상 키보드
466
- attribute.enter_key_hint("search") // Enter 키 동작
467
- attribute.auto_capitalize("words") // 대문자 변환
468
- attribute.capture_("environment") // 카메라 선택
469
- ```
470
-
471
- #### Popover API (Round 3)
472
-
473
- ```gleam
474
- html.button(
475
- [
476
- attribute.popover_target("my-popover"),
477
- attribute.popover_target_action("toggle"),
478
- ],
479
- [react.text("열기")],
480
- )
481
-
482
- html.div(
483
- [attribute.id("my-popover"), attribute.popover("auto")],
484
- [react.text("팝오버 내용")],
485
- )
486
- ```
487
-
488
- ### 3.3 이벤트 핸들러
489
-
490
- `glendix/react/event` 모듈은 15개 이벤트 타입과 148+ 핸들러를 제공합니다.
491
-
492
- #### 이벤트 타입
493
-
494
- | 타입 | 용도 |
495
- |---|---|
496
- | `Event` | 기본/미디어/UI 이벤트 |
497
- | `MouseEvent` | 클릭, 마우스 이벤트 |
498
- | `ChangeEvent` | input 변경 이벤트 |
499
- | `KeyboardEvent` | 키보드 이벤트 |
500
- | `FormEvent` | 폼 제출 이벤트 |
501
- | `FocusEvent` | 포커스/블러 이벤트 |
502
- | `InputEvent` | 입력/beforeinput 이벤트 |
503
- | `PointerEvent` | 포인터 이벤트 |
504
- | `DragEvent` | 드래그 앤 드롭 이벤트 |
505
- | `ClipboardEvent` | 복사/잘라내기/붙여넣기 |
506
- | `TouchEvent` | 터치 이벤트 |
507
- | `WheelEvent` | 휠 스크롤 이벤트 |
508
- | `AnimationEvent` | CSS 애니메이션 이벤트 |
509
- | `TransitionEvent` | CSS 트랜지션 이벤트 |
510
- | `CompositionEvent` | CJK/IME 입력 이벤트 |
511
-
512
- #### 핸들러 사용
513
-
514
- ```gleam
515
- import glendix/react/event
516
-
517
- // 마우스 이벤트
518
- event.on_click(fn(e) { handle_click(e) })
519
- event.on_double_click(fn(e) { Nil })
520
- event.on_context_menu(fn(e) { Nil })
521
- event.on_mouse_enter(fn(e) { Nil })
522
-
523
- // 키보드 이벤트
524
- event.on_key_down(fn(e) {
525
- case event.key(e) {
526
- "Enter" -> submit()
527
- "Escape" -> cancel()
528
- _ -> Nil
529
- }
530
- })
531
-
532
- // 폼/입력 이벤트
533
- event.on_change(fn(e) { set_name(event.target_value(e)) })
534
- event.on_input(fn(e) { Nil })
535
- event.on_submit(fn(e) {
536
- event.prevent_default(e)
537
- handle_submit()
538
- })
539
-
540
- // 포커스 이벤트
541
- event.on_focus(fn(e) { Nil })
542
- event.on_blur(fn(e) { Nil })
543
-
544
- // 로드/에러 이벤트 (Round 3)
545
- event.on_load(fn(e) { Nil }) // img/iframe/script 로드 완료
546
- event.on_error(fn(e) { Nil }) // 리소스 로드 실패
547
-
548
- // 입력 전 이벤트 (Round 3)
549
- event.on_before_input(fn(e) { Nil }) // 입력 값 변경 전 필터링
550
-
551
- // 미디어 이벤트
552
- event.on_play(fn(e) { Nil })
553
- event.on_pause(fn(e) { Nil })
554
- event.on_time_update(fn(e) { Nil })
555
- event.on_load_start(fn(e) { Nil }) // 미디어 로드 시작 (Round 3)
556
-
557
- // 드래그 이벤트
558
- event.on_drag_start(fn(e) { Nil })
559
- event.on_drop(fn(e) { Nil })
560
-
561
- // 컴포지션 이벤트 (한국어 입력 필수)
562
- event.on_composition_start(fn(e) { Nil })
563
- event.on_composition_end(fn(e) { Nil })
564
-
565
- // 캡처 단계 (모든 핸들러에 _capture 접미사)
566
- event.on_click_capture(fn(e) { Nil })
567
- event.on_key_down_capture(fn(e) { Nil })
568
-
569
- // 범용 이벤트 핸들러 (escape hatch)
570
- event.on("onCustomEvent", fn(e) { Nil })
571
- ```
572
-
573
- #### 이벤트 접근자
574
-
575
- ```gleam
576
- // 공통
577
- event.target(e) // 이벤트 대상 요소 (Dynamic)
578
- event.current_target(e) // 핸들러가 등록된 요소 (Dynamic)
579
- event.target_value(e) // input/textarea 값 (String)
580
- event.prevent_default(e) // 기본 동작 방지
581
- event.stop_propagation(e) // 전파 중지
582
- event.bubbles(e) // 버블링 여부 (Bool)
583
- event.cancelable(e) // 취소 가능 여부 (Bool)
584
- event.is_trusted(e) // 사용자 발생 여부 (Bool)
585
- event.time_stamp(e) // 타임스탬프 (Float)
586
- event.native_event(e) // 네이티브 브라우저 이벤트 (Dynamic)
587
- event.is_default_prevented(e)
588
- event.is_propagation_stopped(e)
589
-
590
- // 이벤트 유틸리티 (Round 3)
591
- event.persist(e) // 이벤트 풀링 방지 (React 17+ 호환)
592
- event.is_persistent(e) // 영속적 여부 (Bool)
593
-
594
- // 마우스
595
- event.client_x(e) // Float
596
- event.client_y(e) // Float
597
- event.page_x(e)
598
- event.page_y(e)
599
- event.offset_x(e)
600
- event.offset_y(e)
601
- event.screen_x(e)
602
- event.screen_y(e)
603
- event.movement_x(e)
604
- event.movement_y(e)
605
- event.button(e) // Int (0=좌, 1=중, 2=우)
606
- event.buttons(e)
607
- event.mouse_ctrl_key(e)
608
- event.mouse_shift_key(e)
609
- event.mouse_alt_key(e)
610
- event.mouse_meta_key(e)
611
- event.get_modifier_state(e, "Control")
612
-
613
- // 키보드
614
- event.key(e) // "Enter", "Escape" 등
615
- event.code(e) // "KeyA", "Space" 등
616
- event.ctrl_key(e)
617
- event.shift_key(e)
618
- event.alt_key(e)
619
- event.meta_key(e)
620
- event.repeat(e)
621
-
622
- // 휠
623
- event.delta_x(e)
624
- event.delta_y(e)
625
- event.delta_z(e)
626
- event.delta_mode(e)
627
-
628
- // 터치
629
- event.touches(e) // Dynamic
630
- event.changed_touches(e)
631
- event.target_touches(e)
632
-
633
- // 포인터
634
- event.pointer_id(e)
635
- event.pointer_type(e) // "mouse", "pen", "touch"
636
- event.pressure(e)
637
- event.tilt_x(e)
638
- event.tilt_y(e)
639
- event.pointer_width(e)
640
- event.pointer_height(e)
641
- event.is_primary(e)
642
-
643
- // 애니메이션
644
- event.animation_name(e)
645
- event.animation_elapsed_time(e)
646
- event.animation_pseudo_element(e)
647
-
648
- // 트랜지션
649
- event.property_name(e)
650
- event.transition_elapsed_time(e)
651
- event.transition_pseudo_element(e)
652
-
653
- // 드래그
654
- event.data_transfer(e) // Dynamic
655
-
656
- // 포커스
657
- event.focus_related_target(e) // Dynamic
658
-
659
- // 컴포지션
660
- event.composition_data(e) // String
661
-
662
- // 입력
663
- event.input_data(e) // String
664
-
665
- // 클립보드
666
- event.clipboard_data(e) // Dynamic
667
- ```
668
-
669
- ### 3.4 HTML 태그 함수
670
-
671
- `glendix/react/html` 모듈은 75+ HTML 태그를 위한 편의 함수를 제공합니다. 순수 Gleam으로 구현되어 FFI가 없습니다.
672
-
673
- ```gleam
674
- import glendix/react/html
675
- import glendix/react/attribute
676
-
677
- // Attribute 리스트가 있는 버전
678
- html.div([attribute.class("container")], children)
679
- html.button([attribute.type_("submit"), event.on_click(handler)], children)
680
- html.input([attribute.type_("text"), attribute.value(val)]) // void 엘리먼트
681
-
682
- // Attribute 없는 버전 (언더스코어 접미사)
683
- html.div_(children)
684
- html.span_([react.text("텍스트")])
685
- html.p_([react.text("문단")])
686
- ```
687
-
688
- #### 사용 가능한 태그 목록
689
-
690
- | 카테고리 | 태그 |
691
- |---|---|
692
- | **컨테이너** | `div`, `span`, `section`, `main`, `header`, `footer`, `nav`, `aside`, `article` |
693
- | **텍스트** | `p`, `h1`~`h6`, `strong`, `em`, `small`, `pre`, `code`, `kbd`, `samp`, `var_` |
694
- | **리스트** | `ul`, `ol`, `li`, `dl`, `dt`, `dd` |
695
- | **폼** | `form`, `button`, `label`, `select`, `option`, `textarea`, `fieldset`, `legend`, `datalist`, `optgroup`, `output` |
696
- | **입력** | `input` (void) |
697
- | **테이블** | `table`, `thead`, `tbody`, `tfoot`, `tr`, `td`, `th`, `colgroup`, `col` (void), `caption` |
698
- | **링크/미디어** | `a`, `img` (void), `br` (void), `hr` (void), `video`, `audio`, `source` (void), `track` (void), `picture`, `canvas`, `iframe` (void) |
699
- | **시맨틱** | `details`, `summary`, `dialog`, `figure`, `figcaption`, `blockquote`, `cite`, `abbr`, `mark`, `del`, `ins`, `sub`, `sup`, `time`, `address`, `meter`, `progress` (void), `search`, `hgroup` |
700
- | **루비 주석** | `ruby`, `rt`, `rp` |
701
- | **양방향 텍스트** | `bdi`, `bdo` |
702
- | **기타** | `data_`, `map_`, `wbr` (void), `embed` (void), `area` (void) |
703
-
704
- ### 3.5 SVG 요소
705
-
706
- `glendix/react/svg` 모듈은 57개 SVG 요소를 제공합니다. 순수 Gleam, FFI 없음.
707
-
708
- ```gleam
709
- import glendix/react/svg
710
- import glendix/react/svg_attribute as sa
711
-
712
- svg.svg([sa.view_box("0 0 100 100"), sa.xmlns("http://www.w3.org/2000/svg")], [
713
- svg.circle([sa.cx("50"), sa.cy("50"), sa.r("40"), sa.fill("blue")], []),
714
- svg.text([sa.x("50"), sa.y("55"), sa.text_anchor("middle"), sa.fill("white")], [
715
- react.text("Hello"),
716
- ]),
717
- ])
718
- ```
719
-
720
- #### SVG 요소 목록
721
-
722
- | 카테고리 | 요소 |
723
- |---|---|
724
- | **컨테이너** | `svg`, `g`, `defs`, `symbol`, `use_`, `marker` |
725
- | **도형** | `circle`, `ellipse`, `line`, `path`, `polygon`, `polyline`, `rect` |
726
- | **텍스트** | `text`, `tspan`, `text_path` |
727
- | **그래디언트/패턴** | `linear_gradient`, `radial_gradient`, `stop` (void), `pattern` |
728
- | **필터** | `filter`, `fe_color_matrix`, `fe_composite`, `fe_flood` (void), `fe_gaussian_blur` (void), `fe_merge`, `fe_merge_node` (void), `fe_offset` (void), `fe_blend` (void), `fe_drop_shadow` (void) |
729
- | **필터 프리미티브** | `fe_convolve_matrix`, `fe_diffuse_lighting`, `fe_displacement_map` (void), `fe_distant_light` (void), `fe_image` (void), `fe_morphology` (void), `fe_point_light` (void), `fe_specular_lighting`, `fe_spot_light` (void), `fe_tile`, `fe_turbulence` (void), `fe_func_r/g/b/a` (void), `fe_component_transfer` |
730
- | **클리핑/마스킹** | `clip_path`, `mask` |
731
- | **애니메이션** | `animate` (void), `animate_transform` (void), `set` (void), `mpath` (void) |
732
- | **기타** | `foreign_object`, `image` (void), `title`, `desc`, `switch_` |
733
-
734
- ### 3.6 SVG 속성
735
-
736
- `glendix/react/svg_attribute` 모듈은 97+ SVG 전용 속성을 제공합니다. 순수 Gleam, FFI 없음.
737
-
738
- ```gleam
739
- import glendix/react/svg_attribute as sa
740
-
741
- sa.view_box("0 0 100 100")
742
- sa.fill("red")
743
- sa.stroke("black")
744
- sa.stroke_width("2")
745
- sa.transform("rotate(45)")
746
- sa.d("M10 10 L90 90") // path 데이터
747
- ```
748
-
749
- #### SVG 속성 목록
750
-
751
- | 카테고리 | 속성 |
752
- |---|---|
753
- | **공통** | `view_box`, `xmlns`, `fill`, `stroke`, `stroke_width`, `stroke_linecap`, `stroke_linejoin`, `stroke_dasharray`, `stroke_dashoffset`, `stroke_opacity`, `fill_opacity`, `fill_rule`, `clip_rule`, `opacity`, `transform` |
754
- | **좌표** | `x`, `y`, `x1`, `y1`, `x2`, `y2`, `cx`, `cy`, `r`, `rx`, `ry`, `dx`, `dy` |
755
- | **도형** | `d`, `points`, `path_length` |
756
- | **그래디언트** | `offset`, `stop_color`, `stop_opacity`, `gradient_units`, `gradient_transform`, `spread_method`, `fx`, `fy` |
757
- | **텍스트** | `text_anchor`, `dominant_baseline`, `font_size`, `font_family`, `font_weight`, `letter_spacing`, `text_decoration`, `alignment_baseline`, `baseline_shift`, `writing_mode`, `text_rendering` |
758
- | **참조** | `href`, `xlink_href` |
759
- | **필터** | `filter_attr`, `in_`, `in2`, `result`, `std_deviation`, `flood_color`, `flood_opacity`, `values`, `mode`, `operator_`, `k1`~`k4`, `scale`, `x_channel_selector`, `y_channel_selector` |
760
- | **마커** | `marker_start`, `marker_mid`, `marker_end`, `marker_height`, `marker_width`, `ref_x`, `ref_y`, `orient` |
761
- | **패턴** | `pattern_units`, `pattern_transform`, `pattern_content_units` |
762
- | **클리핑/마스킹** | `clip_path_attr`, `mask_attr`, `clip_path_units`, `mask_units`, `mask_content_units` |
763
- | **렌더링** | `image_rendering`, `shape_rendering`, `color_interpolation`, `color_interpolation_filters` |
764
- | **기타** | `preserve_aspect_ratio`, `overflow`, `cursor`, `visibility`, `pointer_events`, `color`, `display`, `enable_background`, `lighting_color` |
765
-
766
- ### 3.7 React Hooks
767
-
768
- `glendix/react/hook` 모듈은 37개 React Hooks를 제공합니다.
769
-
770
- > Gleam의 튜플 `#(a, b)`은 JavaScript 배열 `[a, b]`과 동일하므로 React Hooks의 반환값과 직접 호환됩니다.
771
-
772
- #### useState — 상태 관리
773
-
774
- ```gleam
775
- import glendix/react/hook
776
-
777
- pub fn counter(_props) -> ReactElement {
778
- let #(count, set_count) = hook.use_state(0)
779
- let #(name, set_name) = hook.use_state("")
780
-
781
- html.div_([
782
- html.p_([react.text("Count: " <> int.to_string(count))]),
783
- html.button(
784
- [event.on_click(fn(_) { set_count(count + 1) })],
785
- [react.text("+1")],
786
- ),
787
- ])
788
- }
789
-
790
- // 업데이터 함수 변형 (stale closure 방지)
791
- let #(count, update_count) = hook.use_state_(0)
792
- update_count(fn(prev) { prev + 1 })
793
-
794
- // 지연 초기화 (비싼 초기값 계산 방지)
795
- let #(data, set_data) = hook.use_lazy_state(fn() {
796
- compute_expensive_initial_value()
797
- })
798
- ```
799
-
800
- #### useEffect — 사이드 이펙트
801
-
802
- ```gleam
803
- // 의존성 배열 지정
804
- hook.use_effect(fn() {
805
- // count가 변경될 때마다 실행
806
- Nil
807
- }, [count])
808
-
809
- // 마운트 시 한 번만 실행
810
- hook.use_effect_once(fn() {
811
- Nil
812
- })
813
-
814
- // 매 렌더링마다 실행
815
- hook.use_effect_always(fn() {
816
- Nil
817
- })
818
- ```
819
-
820
- #### useEffect + 클린업
821
-
822
- ```gleam
823
- // 클린업 함수가 있는 effect
824
- hook.use_effect_once_cleanup(fn() {
825
- // 마운트 시 실행
826
- let timer_id = set_interval(update, 1000)
827
-
828
- // 클린업 함수 반환 (언마운트 시 실행)
829
- fn() { clear_interval(timer_id) }
830
- })
831
-
832
- hook.use_effect_cleanup(fn() {
833
- // effect 실행
834
- fn() { /* 클린업 */ }
835
- }, [dependency])
836
-
837
- hook.use_effect_always_cleanup(fn() {
838
- fn() { /* 매 렌더 후 클린업 */ }
839
- })
840
- ```
841
-
842
- #### useLayoutEffect — 동기 레이아웃 이펙트
843
-
844
- ```gleam
845
- // DOM 변경 후 브라우저 페인트 전 동기 실행
846
- hook.use_layout_effect(fn() {
847
- // DOM 측정 로직
848
- Nil
849
- }, [some_dep])
850
-
851
- // cleanup 변형도 동일 패턴
852
- hook.use_layout_effect_once_cleanup(fn() {
853
- fn() { Nil }
854
- })
855
- ```
856
-
857
- #### useInsertionEffect — CSS-in-JS용
858
-
859
- ```gleam
860
- // DOM 변경 전 실행 (CSS-in-JS 라이브러리용)
861
- hook.use_insertion_effect(fn() {
862
- // 스타일 삽입
863
- Nil
864
- }, [theme])
865
- ```
866
-
867
- #### useMemo — 메모이제이션
868
-
869
- ```gleam
870
- // 값이 비용이 클 때 메모이제이션
871
- let expensive_result = hook.use_memo(fn() {
872
- compute_expensive_value(data)
873
- }, [data])
874
- ```
875
-
876
- #### useCallback — 콜백 메모이제이션
877
-
878
- ```gleam
879
- // 콜백 함수 메모이제이션 (자식 컴포넌트에 전달할 때 유용)
880
- let handle_click = hook.use_callback(fn(event) {
881
- set_count(count + 1)
882
- }, [count])
883
- ```
884
-
885
- #### useRef — 참조
886
-
887
- ```gleam
888
- let input_ref = hook.use_ref(Nil)
889
-
890
- // ref 값 읽기
891
- let current = hook.get_ref(input_ref)
892
-
893
- // ref 값 쓰기
894
- hook.set_ref(input_ref, new_value)
895
-
896
- // DOM 요소에 연결
897
- html.input([attribute.ref(input_ref)])
898
- ```
899
-
900
- #### useReducer — 리듀서 기반 상태
901
-
902
- ```gleam
903
- let #(state, dispatch) = hook.use_reducer(
904
- fn(state, action) {
905
- case action {
906
- Increment -> State(..state, count: state.count + 1)
907
- Decrement -> State(..state, count: state.count - 1)
908
- }
909
- },
910
- initial_state,
911
- )
912
-
913
- dispatch(Increment)
914
- ```
915
-
916
- #### useContext — Context 값 읽기
917
-
918
- ```gleam
919
- let theme = hook.use_context(theme_context)
920
- ```
921
-
922
- #### useId — 고유 ID 생성
923
-
924
- ```gleam
925
- let id = hook.use_id() // SSR-safe 고유 ID
926
- ```
927
-
928
- #### useTransition — 비긴급 업데이트
929
-
930
- ```gleam
931
- let #(is_pending, start_transition) = hook.use_transition()
932
-
933
- start_transition(fn() {
934
- // 비긴급 상태 업데이트
935
- set_search_results(filter(data, query))
936
- })
937
- ```
938
-
939
- #### useDeferredValue — 값 지연
940
-
941
- ```gleam
942
- let deferred_query = hook.use_deferred_value(query)
943
- ```
944
-
945
- #### useOptimistic — 낙관적 UI (React 19)
946
-
947
- ```gleam
948
- // 간단한 형태
949
- let #(optimistic_items, add_optimistic) = hook.use_optimistic(items)
950
- add_optimistic(new_item)
951
-
952
- // 리듀서 변형 — 업데이트 함수로 병합 로직 지정 (Round 3)
953
- let #(optimistic_items, add_optimistic) = hook.use_optimistic_(
954
- items,
955
- fn(current, new_item) { list.append(current, [new_item]) },
956
- )
957
- add_optimistic(new_item)
958
- ```
959
-
960
- #### useImperativeHandle — ref 커스터마이징
961
-
962
- ```gleam
963
- hook.use_imperative_handle(ref, fn() {
964
- // 부모에게 노출할 인터페이스
965
- my_interface
966
- }, [dep])
967
- ```
968
-
969
- #### useSyncExternalStore — 외부 스토어
970
-
971
- ```gleam
972
- let value = hook.use_sync_external_store(subscribe, get_snapshot)
973
- ```
974
-
975
- #### useDebugValue — DevTools 디버그
976
-
977
- ```gleam
978
- hook.use_debug_value(state)
979
- hook.use_debug_value_(state, fn(s) { "State: " <> string.inspect(s) })
980
- ```
981
-
982
- ### 3.8 조건부 렌더링
983
-
984
- ```gleam
985
- import glendix/react
986
-
987
- // Bool 기반 — 조건이 True일 때만 렌더링
988
- react.when(is_logged_in, fn() {
989
- html.div_([react.text("환영합니다!")])
990
- })
991
-
992
- // Option 기반 — Some일 때만 렌더링
993
- react.when_some(maybe_user, fn(user) {
994
- html.span_([react.text(user.name)])
995
- })
996
-
997
- // case 표현식으로 복잡한 조건 처리
998
- case status {
999
- Loading -> html.div_([react.text("로딩 중...")])
1000
- Available -> html.div_([react.text("완료")])
1001
- Unavailable -> react.none()
1002
- }
1003
- ```
1004
-
1005
- ### 3.9 리스트 렌더링
1006
-
1007
- ```gleam
1008
- import gleam/list
1009
-
1010
- // 리스트를 map하여 엘리먼트 생성
1011
- let items = ["사과", "바나나", "체리"]
1012
-
1013
- html.ul_(
1014
- list.map(items, fn(item) {
1015
- html.li([attribute.key(item)], [
1016
- react.text(item),
1017
- ])
1018
- }),
1019
- )
1020
-
1021
- // 인덱스가 필요한 경우
1022
- list.index_map(items, fn(item, idx) {
1023
- html.li([attribute.key(int.to_string(idx))], [
1024
- react.text(item),
1025
- ])
1026
- })
1027
- ```
1028
-
1029
- > 리스트 렌더링 시 항상 `attribute.key()`를 설정하세요. React의 reconciliation에 필요합니다.
1030
-
1031
- ### 3.10 인라인 스타일
1032
-
1033
- ```gleam
1034
- import glendix/react/attribute
1035
-
1036
- // 튜플 리스트로 스타일 지정 (CSS 속성명은 자동 camelCase 변환)
1037
- html.div(
1038
- [attribute.style([
1039
- #("background-color", "#f0f0f0"),
1040
- #("padding", "16px"),
1041
- #("border-radius", "8px"),
1042
- ])],
1043
- children,
1044
- )
1045
- ```
1046
-
1047
- ### 3.11 고급 컴포넌트
1048
-
1049
- #### 컴포넌트 정의
1050
-
1051
- ```gleam
1052
- // 이름 있는 컴포넌트 (DevTools에 표시)
1053
- let my_component = react.define_component("MyComponent", fn(props) {
1054
- html.div_([react.text("Hello")])
1055
- })
1056
-
1057
- // React.memo (props 동일 시 리렌더 방지)
1058
- let memoized = react.memo(my_component)
1059
-
1060
- // 커스텀 비교 함수
1061
- let memoized = react.memo_(my_component, fn(prev, next) {
1062
- // True면 리렌더 건너뜀
1063
- prev == next
1064
- })
1065
- ```
1066
-
1067
- #### StrictMode
1068
-
1069
- ```gleam
1070
- react.strict_mode([
1071
- // 개발 모드 이중 렌더링 감지
1072
- my_widget(props),
1073
- ])
1074
- ```
1075
-
1076
- #### Suspense
1077
-
1078
- ```gleam
1079
- react.suspense(
1080
- html.div_([react.text("로딩 중...")]), // fallback
1081
- [lazy_component], // children
1082
- )
1083
- ```
1084
-
1085
- #### Portal
1086
-
1087
- ```gleam
1088
- // 위젯 DOM 외부에 렌더링 (모달, 팝업)
1089
- react.portal(modal_element, document_body)
1090
- ```
1091
-
1092
- #### forwardRef
1093
-
1094
- ```gleam
1095
- let fancy_input = react.forward_ref(fn(props, ref) {
1096
- html.input([attribute.ref(ref), attribute.class("fancy")])
1097
- })
1098
- ```
1099
-
1100
- #### startTransition / flushSync
1101
-
1102
- ```gleam
1103
- // 훅 없이 비긴급 업데이트 표시
1104
- react.start_transition(fn() {
1105
- set_data(new_data)
1106
- })
1107
-
1108
- // 동기 DOM 업데이트 강제 (상태 변경 후 DOM 측정 시 필요) (Round 3)
1109
- react.flush_sync(fn() {
1110
- set_count(count + 1)
1111
- })
1112
- // 이 시점에 DOM이 이미 업데이트되어 있음
1113
- ```
1114
-
1115
- #### Profiler
1116
-
1117
- ```gleam
1118
- react.profiler("MyWidget", fn(id, phase, actual, base, start, commit) {
1119
- // 렌더링 성능 측정
1120
- Nil
1121
- }, [my_widget(props)])
1122
- ```
1123
-
1124
- #### Context API
1125
-
1126
- ```gleam
1127
- // Context 생성
1128
- let theme_ctx = react.create_context("light")
1129
-
1130
- // Provider로 값 공급
1131
- react.provider(theme_ctx, "dark", [
1132
- child_component,
1133
- ])
1134
-
1135
- // 소비 (hook)
1136
- let theme = hook.use_context(theme_ctx)
1137
- ```
1138
-
1139
- ---
1140
-
1141
- ## 4. Mendix 바인딩
1142
-
1143
- ### 4.1 Props 접근
1144
-
1145
- `glendix/mendix` 모듈로 Mendix가 위젯에 전달하는 props에 접근합니다.
1146
-
1147
- ```gleam
1148
- import glendix/mendix
1149
-
1150
- // Option 반환 (undefined면 None)
1151
- case mendix.get_prop(props, "myAttribute") {
1152
- Some(attr) -> use_attribute(attr)
1153
- None -> react.none()
1154
- }
1155
-
1156
- // 항상 존재하는 prop (undefined일 수 없는 경우)
1157
- let value = mendix.get_prop_required(props, "alwaysPresent")
1158
-
1159
- // String prop (없으면 빈 문자열 반환)
1160
- let text = mendix.get_string_prop(props, "caption")
1161
-
1162
- // prop 존재 여부 확인
1163
- let has_action = mendix.has_prop(props, "onClick")
1164
- ```
1165
-
1166
- #### ValueStatus 확인
1167
-
1168
- Mendix의 모든 동적 값은 상태(status)를 가집니다:
1169
-
1170
- ```gleam
1171
- import glendix/mendix.{Available, Loading, Unavailable}
1172
-
1173
- case mendix.get_status(some_value) {
1174
- Available -> // 값 사용 가능
1175
- Loading -> // 로딩 중
1176
- Unavailable -> // 사용 불가
1177
- }
1178
- ```
1179
-
1180
- ### 4.2 EditableValue — 편집 가능한 값
1181
-
1182
- `glendix/mendix/editable_value`는 텍스트, 숫자, 날짜 등 편집 가능한 Mendix 속성을 다룹니다.
1183
-
1184
- ```gleam
1185
- import gleam/option.{None, Some}
1186
- import glendix/mendix
1187
- import glendix/mendix/editable_value as ev
1188
-
1189
- pub fn text_input(props: JsProps) -> ReactElement {
1190
- case mendix.get_prop(props, "textAttribute") {
1191
- Some(attr) -> render_input(attr)
1192
- None -> react.none()
1193
- }
1194
- }
1195
-
1196
- fn render_input(attr) -> ReactElement {
1197
- // 값 읽기
1198
- let current_value = ev.value(attr) // Option(a)
1199
- let display = ev.display_value(attr) // String (포맷된 표시값)
1200
- let is_editable = ev.is_editable(attr) // Bool (Available && !read_only)
1201
-
1202
- // 유효성 검사 메시지 확인
1203
- let validation_msg = ev.validation(attr) // Option(String)
1204
-
1205
- html.div_([
1206
- html.input([
1207
- attribute.value(display),
1208
- attribute.readonly(!is_editable),
1209
- event.on_change(fn(e) {
1210
- // 텍스트로 값 설정 (Mendix가 파싱)
1211
- ev.set_text_value(attr, event.target_value(e))
1212
- }),
1213
- ]),
1214
- // 유효성 검사 에러 표시
1215
- react.when_some(validation_msg, fn(msg) {
1216
- html.span([attribute.class("text-danger")], [
1217
- react.text(msg),
1218
- ])
1219
- }),
1220
- ])
1221
- }
1222
- ```
1223
-
1224
- #### 값 설정 방법
1225
-
1226
- ```gleam
1227
- // Option으로 직접 설정 (타입이 맞아야 함)
1228
- ev.set_value(attr, Some(new_value)) // 값 설정
1229
- ev.set_value(attr, None) // 값 비우기
1230
-
1231
- // 텍스트로 설정 (Mendix가 자동 파싱 — 숫자, 날짜 등에 유용)
1232
- ev.set_text_value(attr, "2024-01-15")
1233
-
1234
- // 커스텀 유효성 검사 함수 설정
1235
- ev.set_validator(attr, Some(fn(value) {
1236
- case value {
1237
- Some(v) if v == "" -> Some("값을 입력하세요")
1238
- _ -> None // 유효함
1239
- }
1240
- }))
1241
- ```
1242
-
1243
- #### 가능한 값 목록 (Enum, Boolean 등)
1244
-
1245
- ```gleam
1246
- case ev.universe(attr) {
1247
- Some(options) ->
1248
- // options: List(a) — 선택 가능한 모든 값
1249
- html.select_(
1250
- list.map(options, fn(opt) {
1251
- html.option_([react.text(string.inspect(opt))])
1252
- }),
1253
- )
1254
- None -> react.none()
1255
- }
1256
- ```
1257
-
1258
- ### 4.3 ActionValue — 액션 실행
1259
-
1260
- `glendix/mendix/action`으로 Mendix 마이크로플로우/나노플로우를 실행합니다.
1261
-
1262
- ```gleam
1263
- import glendix/mendix
1264
- import glendix/mendix/action
1265
-
1266
- pub fn action_button(props: JsProps) -> ReactElement {
1267
- let on_click = mendix.get_prop(props, "onClick") // Option(ActionValue)
1268
-
1269
- html.button(
1270
- [
1271
- attribute.class("btn"),
1272
- attribute.disabled(case on_click {
1273
- Some(a) -> !action.can_execute(a)
1274
- None -> True
1275
- }),
1276
- event.on_click(fn(_) {
1277
- // Option(ActionValue) 안전하게 실행
1278
- action.execute_action(on_click)
1279
- }),
1280
- ],
1281
- [react.text("실행")],
1282
- )
1283
- }
1284
- ```
1285
-
1286
- #### 액션 실행 방법
1287
-
1288
- ```gleam
1289
- // 직접 실행 (can_execute 확인 없이)
1290
- action.execute(my_action)
1291
-
1292
- // can_execute가 True일 때만 실행
1293
- action.execute_if_can(my_action)
1294
-
1295
- // Option(ActionValue)에서 안전하게 실행
1296
- action.execute_action(maybe_action) // None이면 아무것도 안 함
1297
-
1298
- // 실행 상태 확인
1299
- let can = action.can_execute(my_action) // Bool
1300
- let running = action.is_executing(my_action) // Bool
1301
- ```
1302
-
1303
- ### 4.4 DynamicValue — 읽기 전용 표현식
1304
-
1305
- `glendix/mendix/dynamic_value`는 Mendix 표현식(Expression) 속성을 다룹니다.
1306
-
1307
- ```gleam
1308
- import glendix/mendix/dynamic_value as dv
1309
-
1310
- pub fn display_expression(props: JsProps) -> ReactElement {
1311
- case mendix.get_prop(props, "expression") {
1312
- Some(expr) ->
1313
- case dv.value(expr) {
1314
- Some(text) -> html.span_([react.text(text)])
1315
- None -> react.none()
1316
- }
1317
- None -> react.none()
1318
- }
1319
- }
1320
-
1321
- // 상태 확인
1322
- let status = dv.status(expr)
1323
- let ready = dv.is_available(expr)
1324
- ```
1325
-
1326
- ### 4.5 ListValue — 리스트 데이터
1327
-
1328
- `glendix/mendix/list_value`는 Mendix 데이터 소스 리스트를 다룹니다.
1329
-
1330
- ```gleam
1331
- import glendix/mendix
1332
- import glendix/mendix/list_value as lv
1333
-
1334
- pub fn data_list(props: JsProps) -> ReactElement {
1335
- case mendix.get_prop(props, "dataSource") {
1336
- Some(list_val) -> render_list(list_val, props)
1337
- None -> react.none()
1338
- }
1339
- }
1340
-
1341
- fn render_list(list_val, props) -> ReactElement {
1342
- case lv.items(list_val) {
1343
- Some(items) ->
1344
- html.ul_(
1345
- list.map(items, fn(item) {
1346
- let id = mendix.object_id(item)
1347
- html.li([attribute.key(id)], [
1348
- react.text("Item: " <> id),
1349
- ])
1350
- }),
1351
- )
1352
- None ->
1353
- html.div_([react.text("로딩 중...")])
1354
- }
1355
- }
1356
- ```
1357
-
1358
- #### 페이지네이션
1359
-
1360
- ```gleam
1361
- // 현재 페이지 정보
1362
- let offset = lv.offset(list_val) // 현재 오프셋
1363
- let limit = lv.limit(list_val) // 페이지 크기
1364
- let has_more = lv.has_more_items(list_val) // Option(Bool)
1365
-
1366
- // 페이지 이동
1367
- lv.set_offset(list_val, offset + limit) // 다음 페이지
1368
- lv.set_limit(list_val, 20) // 페이지 크기 변경
1369
-
1370
- // 전체 개수 요청 (성능 고려)
1371
- lv.request_total_count(list_val, True)
1372
- let total = lv.total_count(list_val) // Option(Int)
1373
- ```
1374
-
1375
- #### 정렬
1376
-
1377
- ```gleam
1378
- import glendix/mendix/list_value as lv
1379
-
1380
- // 정렬 적용
1381
- lv.set_sort_order(list_val, [
1382
- lv.sort("Name", lv.Asc),
1383
- lv.sort("CreatedDate", lv.Desc),
1384
- ])
1385
-
1386
- // 현재 정렬 확인
1387
- let current_sort = lv.sort_order(list_val)
1388
- ```
1389
-
1390
- #### 데이터 갱신
1391
-
1392
- ```gleam
1393
- lv.reload(list_val) // 데이터 다시 로드
1394
- ```
1395
-
1396
- ### 4.6 ListAttribute — 리스트 아이템 접근
1397
-
1398
- `glendix/mendix/list_attribute`는 리스트의 각 아이템에서 속성, 액션, 표현식, 위젯을 추출합니다.
1399
-
1400
- ```gleam
1401
- import glendix/mendix/list_attribute as la
1402
-
1403
- pub fn render_table(props: JsProps) -> ReactElement {
1404
- let list_val = mendix.get_prop_required(props, "dataSource")
1405
- let name_attr = mendix.get_prop_required(props, "nameAttr")
1406
- let edit_action = mendix.get_prop(props, "onEdit")
1407
-
1408
- case lv.items(list_val) {
1409
- Some(items) ->
1410
- html.table_([
1411
- html.tbody_(
1412
- list.map(items, fn(item) {
1413
- let id = mendix.object_id(item)
1414
-
1415
- // 아이템에서 속성값 추출
1416
- let name_ev = la.get_attribute(name_attr, item)
1417
- let display = ev.display_value(name_ev)
1418
-
1419
- // 아이템에서 액션 추출
1420
- let action_opt = case edit_action {
1421
- Some(act) -> la.get_action(act, item)
1422
- None -> None
1423
- }
1424
-
1425
- html.tr([attribute.key(id)], [
1426
- html.td_([react.text(display)]),
1427
- html.td_([
1428
- html.button(
1429
- [event.on_click(fn(_) {
1430
- action.execute_action(action_opt)
1431
- })],
1432
- [react.text("편집")],
1433
- ),
1434
- ]),
1435
- ])
1436
- }),
1437
- ),
1438
- ])
1439
- None -> html.div_([react.text("로딩 중...")])
1440
- }
1441
- }
1442
- ```
1443
-
1444
- #### ListAttributeValue 메타데이터
1445
-
1446
- ```gleam
1447
- // 속성 정보 확인
1448
- let id = la.attr_id(name_attr) // String - 속성 ID
1449
- let sortable = la.attr_sortable(name_attr) // Bool
1450
- let filterable = la.attr_filterable(name_attr) // Bool
1451
- let type_name = la.attr_type(name_attr) // "String", "Integer" 등
1452
- let formatter = la.attr_formatter(name_attr) // ValueFormatter
1453
- ```
1454
-
1455
- #### 위젯 렌더링
1456
-
1457
- ```gleam
1458
- // 리스트 아이템별 위젯 (Mendix Studio에서 구성)
1459
- let content_widget = mendix.get_prop_required(props, "content")
1460
-
1461
- list.map(items, fn(item) {
1462
- let widget_element = la.get_widget(content_widget, item)
1463
- html.div([attribute.key(mendix.object_id(item))], [
1464
- widget_element, // ReactElement로 직접 사용
1465
- ])
1466
- })
1467
- ```
1468
-
1469
- ### 4.7 Selection — 선택
1470
-
1471
- `glendix/mendix/selection`으로 단일/다중 선택을 관리합니다.
1472
-
1473
- #### 단일 선택
1474
-
1475
- ```gleam
1476
- import glendix/mendix/selection
1477
-
1478
- // 현재 선택된 항목
1479
- let selected = selection.selection(single_sel) // Option(ObjectItem)
1480
-
1481
- // 선택 설정/해제
1482
- selection.set_selection(single_sel, Some(item)) // 선택
1483
- selection.set_selection(single_sel, None) // 선택 해제
1484
- ```
1485
-
1486
- #### 다중 선택
1487
-
1488
- ```gleam
1489
- // 선택된 항목들
1490
- let selected_items = selection.selections(multi_sel) // List(ObjectItem)
1491
-
1492
- // 선택 설정
1493
- selection.set_selections(multi_sel, [item1, item2])
1494
- ```
1495
-
1496
- ### 4.8 Reference — 연관 관계
1497
-
1498
- `glendix/mendix/reference`로 단일 연관 관계, `glendix/mendix/reference_set`으로 다중 연관 관계를 다룹니다.
1499
-
1500
- ```gleam
1501
- import glendix/mendix/reference as ref
1502
- import glendix/mendix/reference_set as ref_set
1503
-
1504
- // 단일 참조 (1:1, N:1)
1505
- let referenced = ref.value(my_ref) // Option(a)
1506
- let is_readonly = ref.read_only(my_ref) // Bool
1507
- let error = ref.validation(my_ref) // Option(String)
1508
-
1509
- ref.set_value(my_ref, Some(new_item)) // 참조 설정
1510
- ref.set_value(my_ref, None) // 참조 해제
1511
-
1512
- // 다중 참조 (M:N)
1513
- let items = ref_set.value(my_ref_set) // Option(List(a))
1514
- ref_set.set_value(my_ref_set, Some([item1, item2]))
1515
- ```
1516
-
1517
- ### 4.9 Filter — 필터 조건 빌더
1518
-
1519
- `glendix/mendix/filter`로 ListValue에 적용할 필터 조건을 프로그래밍 방식으로 구성합니다.
1520
-
1521
- ```gleam
1522
- import glendix/mendix/filter
1523
- import glendix/mendix/list_value as lv
1524
-
1525
- // 단순 비교
1526
- let name_filter =
1527
- filter.contains(
1528
- filter.attribute("Name"),
1529
- filter.literal("검색어"),
1530
- )
1531
-
1532
- // 복합 조건 (AND)
1533
- let complex_filter =
1534
- filter.and_([
1535
- filter.equals(
1536
- filter.attribute("Status"),
1537
- filter.literal("Active"),
1538
- ),
1539
- filter.greater_than(
1540
- filter.attribute("Amount"),
1541
- filter.literal(100),
1542
- ),
1543
- ])
1544
-
1545
- // 필터 적용
1546
- lv.set_filter(list_val, Some(complex_filter))
1547
-
1548
- // 필터 해제
1549
- lv.set_filter(list_val, None)
1550
- ```
1551
-
1552
- #### 사용 가능한 비교 연산자
1553
-
1554
- | 함수 | 설명 |
1555
- |---|---|
1556
- | `equals(a, b)` | 같음 |
1557
- | `not_equal(a, b)` | 다름 |
1558
- | `greater_than(a, b)` | 초과 |
1559
- | `greater_than_or_equal(a, b)` | 이상 |
1560
- | `less_than(a, b)` | 미만 |
1561
- | `less_than_or_equal(a, b)` | 이하 |
1562
- | `contains(a, b)` | 포함 (문자열) |
1563
- | `starts_with(a, b)` | 시작 (문자열) |
1564
- | `ends_with(a, b)` | 끝 (문자열) |
1565
-
1566
- #### 날짜 비교
1567
-
1568
- ```gleam
1569
- filter.day_equals(filter.attribute("Birthday"), filter.literal(date))
1570
- filter.day_greater_than(filter.attribute("CreatedDate"), filter.literal(start_date))
1571
- ```
1572
-
1573
- #### 논리 조합
1574
-
1575
- ```gleam
1576
- filter.and_([condition1, condition2]) // AND
1577
- filter.or_([condition1, condition2]) // OR
1578
- filter.not_(condition) // NOT
1579
- ```
1580
-
1581
- #### 표현식 타입
1582
-
1583
- ```gleam
1584
- filter.attribute("AttrName") // 속성 참조
1585
- filter.association("AssocName") // 연관 관계 참조
1586
- filter.literal(value) // 상수 값
1587
- filter.empty() // 빈 값 (null 비교용)
1588
- ```
1589
-
1590
- ### 4.10 날짜와 숫자
1591
-
1592
- #### JsDate — 날짜 처리
1593
-
1594
- `glendix/mendix/date`는 JavaScript Date를 Gleam에서 안전하게 다룹니다.
1595
-
1596
- > 핵심: Gleam에서 월(month)은 **1-based** (1~12), JavaScript에서는 0-based (0~11). glendix가 자동 변환합니다.
1597
-
1598
- ```gleam
1599
- import glendix/mendix/date
1600
-
1601
- // 생성
1602
- let now = date.now()
1603
- let parsed = date.from_iso("2024-03-15T10:30:00Z")
1604
- let custom = date.create(2024, 3, 15, 10, 30, 0, 0) // 월: 1-12!
1605
- let from_ts = date.from_timestamp(1710500000000)
1606
-
1607
- // 읽기
1608
- let year = date.year(now) // 예: 2024
1609
- let month = date.month(now) // 1~12 (자동 변환!)
1610
- let day = date.day(now) // 1~31
1611
- let hours = date.hours(now) // 0~23
1612
- let dow = date.day_of_week(now) // 0=일요일
1613
-
1614
- // 변환
1615
- let iso = date.to_iso(now) // "2024-03-15T10:30:00.000Z"
1616
- let ts = date.to_timestamp(now) // Unix 밀리초
1617
- let str = date.to_string(now) // 사람이 읽을 수 있는 형식
1618
- let input_val = date.to_input_value(now) // "2024-03-15" (input[type="date"]용)
1619
-
1620
- // input[type="date"]에서 파싱
1621
- let maybe_date = date.from_input_value("2024-03-15") // Option(JsDate)
1622
- ```
1623
-
1624
- #### Big — 고정밀 십진수
1625
-
1626
- `glendix/mendix/big`는 Big.js를 래핑하여 Mendix의 Decimal 타입을 정밀하게 처리합니다.
1627
-
1628
- ```gleam
1629
- import glendix/mendix/big
1630
- import gleam/order
1631
-
1632
- // 생성
1633
- let a = big.from_string("123.456")
1634
- let b = big.from_int(100)
1635
- let c = big.from_float(99.99)
1636
-
1637
- // 연산
1638
- let sum = big.add(a, b) // 223.456
1639
- let diff = big.subtract(a, b) // 23.456
1640
- let prod = big.multiply(a, b) // 12345.6
1641
- let quot = big.divide(a, b) // 1.23456
1642
- let abs = big.absolute(diff) // 양수화
1643
- let neg = big.negate(a) // -123.456
1644
-
1645
- // 비교
1646
- let cmp = big.compare(a, b) // order.Gt
1647
- let eq = big.equal(a, b) // False
1648
-
1649
- // 변환
1650
- let str = big.to_string(sum) // "223.456"
1651
- let f = big.to_float(sum) // 223.456
1652
- let i = big.to_int(sum) // 223 (소수점 버림)
1653
- let fixed = big.to_fixed(sum, 2) // "223.46"
1654
- ```
1655
-
1656
- ### 4.11 파일, 아이콘, 포맷터
1657
-
1658
- #### FileValue / WebImage
1659
-
1660
- ```gleam
1661
- import glendix/mendix/file
1662
-
1663
- // FileValue
1664
- let uri = file.uri(file_val) // String - 파일 URI
1665
- let name = file.name(file_val) // Option(String) - 파일명
1666
-
1667
- // WebImage (FileValue + alt 텍스트)
1668
- let src = file.image_uri(img) // String
1669
- let alt = file.alt_text(img) // Option(String)
1670
-
1671
- html.img([
1672
- attribute.src(src),
1673
- attribute.alt(option.unwrap(alt, "")),
1674
- ])
1675
- ```
1676
-
1677
- #### WebIcon
1678
-
1679
- ```gleam
1680
- import glendix/mendix/icon
1681
-
1682
- case icon.icon_type(my_icon) {
1683
- icon.Glyph ->
1684
- html.span([attribute.class(icon.icon_class(my_icon))], [])
1685
- icon.Image ->
1686
- html.img([attribute.src(icon.icon_url(my_icon))])
1687
- icon.IconFont ->
1688
- html.span([attribute.class(icon.icon_class(my_icon))], [])
1689
- }
1690
- ```
1691
-
1692
- #### ValueFormatter
1693
-
1694
- ```gleam
1695
- import glendix/mendix/formatter
1696
-
1697
- // 값을 문자열로 포맷
1698
- let display = formatter.format(fmt, Some(value)) // String
1699
- let empty = formatter.format(fmt, None) // ""
1700
-
1701
- // 텍스트를 값으로 파싱
1702
- case formatter.parse(fmt, "123.45") {
1703
- Ok(Some(value)) -> // 파싱 성공
1704
- Ok(None) -> // 빈 값
1705
- Error(Nil) -> // 파싱 실패
1706
- }
1707
- ```
1708
-
1709
- ---
1710
-
1711
- ## 5. 실전 패턴
1712
-
1713
- ### 5.1 폼 입력 위젯
1714
-
1715
- ```gleam
1716
- import gleam/option.{None, Some}
1717
- import glendix/mendix
1718
- import glendix/mendix/action
1719
- import glendix/mendix/editable_value as ev
1720
- import glendix/react.{type JsProps, type ReactElement}
1721
- import glendix/react/attribute
1722
- import glendix/react/event
1723
- import glendix/react/hook
1724
- import glendix/react/html
1725
-
1726
- pub fn text_input_widget(props: JsProps) -> ReactElement {
1727
- let attr = mendix.get_prop(props, "textAttribute")
1728
- let on_enter = mendix.get_prop(props, "onEnterAction")
1729
- let placeholder = mendix.get_string_prop(props, "placeholder")
1730
-
1731
- case attr {
1732
- Some(text_attr) -> {
1733
- let display = ev.display_value(text_attr)
1734
- let editable = ev.is_editable(text_attr)
1735
- let validation = ev.validation(text_attr)
1736
-
1737
- html.div([attribute.class("form-group")], [
1738
- html.input([
1739
- attribute.class("form-control"),
1740
- attribute.value(display),
1741
- attribute.placeholder(placeholder),
1742
- attribute.readonly(!editable),
1743
- event.on_change(fn(e) {
1744
- ev.set_text_value(text_attr, event.target_value(e))
1745
- }),
1746
- event.on_key_down(fn(e) {
1747
- case event.key(e) {
1748
- "Enter" -> action.execute_action(on_enter)
1749
- _ -> Nil
1750
- }
1751
- }),
1752
- ]),
1753
- // 유효성 검사 메시지
1754
- react.when_some(validation, fn(msg) {
1755
- html.div([attribute.class("alert alert-danger")], [
1756
- react.text(msg),
1757
- ])
1758
- }),
1759
- ])
1760
- }
1761
- None -> react.none()
1762
- }
1763
- }
1764
- ```
1765
-
1766
- ### 5.2 데이터 테이블 위젯
1767
-
1768
- ```gleam
1769
- import gleam/int
1770
- import gleam/list
1771
- import gleam/option.{None, Some}
1772
- import glendix/mendix
1773
- import glendix/mendix/editable_value as ev
1774
- import glendix/mendix/list_attribute as la
1775
- import glendix/mendix/list_value as lv
1776
- import glendix/react.{type JsProps, type ReactElement}
1777
- import glendix/react/attribute
1778
- import glendix/react/event
1779
- import glendix/react/html
1780
-
1781
- pub fn data_table(props: JsProps) -> ReactElement {
1782
- let ds = mendix.get_prop_required(props, "dataSource")
1783
- let col_name = mendix.get_prop_required(props, "nameColumn")
1784
- let col_status = mendix.get_prop_required(props, "statusColumn")
1785
-
1786
- html.div([attribute.class("table-responsive")], [
1787
- html.table([attribute.class("table table-striped")], [
1788
- // 헤더
1789
- html.thead_([
1790
- html.tr_([
1791
- html.th_([react.text("이름")]),
1792
- html.th_([react.text("상태")]),
1793
- ]),
1794
- ]),
1795
- // 바디
1796
- html.tbody_(
1797
- case lv.items(ds) {
1798
- Some(items) ->
1799
- list.map(items, fn(item) {
1800
- let id = mendix.object_id(item)
1801
- let name = ev.display_value(la.get_attribute(col_name, item))
1802
- let status = ev.display_value(la.get_attribute(col_status, item))
1803
-
1804
- html.tr([attribute.key(id)], [
1805
- html.td_([react.text(name)]),
1806
- html.td_([react.text(status)]),
1807
- ])
1808
- })
1809
- None -> [
1810
- html.tr_([
1811
- html.td(
1812
- [attribute.col_span(2)],
1813
- [react.text("로딩 중...")],
1814
- ),
1815
- ]),
1816
- ]
1817
- },
1818
- ),
1819
- ]),
1820
- // 페이지네이션
1821
- render_pagination(ds),
1822
- ])
1823
- }
1824
-
1825
- fn render_pagination(ds) -> ReactElement {
1826
- let offset = lv.offset(ds)
1827
- let limit = lv.limit(ds)
1828
- let has_more = lv.has_more_items(ds)
1829
-
1830
- html.div([attribute.class("pagination")], [
1831
- html.button(
1832
- [
1833
- attribute.disabled(offset == 0),
1834
- event.on_click(fn(_) {
1835
- lv.set_offset(ds, int.max(0, offset - limit))
1836
- }),
1837
- ],
1838
- [react.text("이전")],
1839
- ),
1840
- html.button(
1841
- [
1842
- attribute.disabled(has_more == Some(False)),
1843
- event.on_click(fn(_) {
1844
- lv.set_offset(ds, offset + limit)
1845
- }),
1846
- ],
1847
- [react.text("다음")],
1848
- ),
1849
- ])
1850
- }
1851
- ```
1852
-
1853
- ### 5.3 검색 가능한 리스트
1854
-
1855
- ```gleam
1856
- import gleam/option.{None, Some}
1857
- import glendix/mendix
1858
- import glendix/mendix/filter
1859
- import glendix/mendix/list_value as lv
1860
- import glendix/react.{type JsProps, type ReactElement}
1861
- import glendix/react/attribute
1862
- import glendix/react/event
1863
- import glendix/react/hook
1864
- import glendix/react/html
1865
-
1866
- pub fn searchable_list(props: JsProps) -> ReactElement {
1867
- let ds = mendix.get_prop_required(props, "dataSource")
1868
- let search_attr = mendix.get_string_prop(props, "searchAttribute")
1869
- let #(query, set_query) = hook.use_state("")
1870
-
1871
- // 검색어 변경 시 필터 적용
1872
- hook.use_effect(fn() {
1873
- case query {
1874
- "" -> lv.set_filter(ds, None)
1875
- q ->
1876
- lv.set_filter(ds, Some(
1877
- filter.contains(
1878
- filter.attribute(search_attr),
1879
- filter.literal(q),
1880
- ),
1881
- ))
1882
- }
1883
- Nil
1884
- }, [query])
1885
-
1886
- html.div_([
1887
- // 검색 입력
1888
- html.input([
1889
- attribute.class("form-control"),
1890
- attribute.type_("search"),
1891
- attribute.placeholder("검색..."),
1892
- attribute.value(query),
1893
- event.on_change(fn(e) { set_query(event.target_value(e)) }),
1894
- ]),
1895
- // 결과 리스트 렌더링
1896
- render_results(ds),
1897
- ])
1898
- }
1899
- ```
1900
-
1901
- ### 5.4 컴포넌트 합성
1902
-
1903
- Gleam 함수를 컴포넌트처럼 활용하여 UI를 분리합니다:
1904
-
1905
- ```gleam
1906
- import glendix/react.{type ReactElement}
1907
- import glendix/react/attribute
1908
- import glendix/react/html
1909
-
1910
- // 재사용 가능한 카드 컴포넌트
1911
- fn card(title: String, children: List(ReactElement)) -> ReactElement {
1912
- html.div([attribute.class("card")], [
1913
- html.div([attribute.class("card-header")], [
1914
- html.h3_([react.text(title)]),
1915
- ]),
1916
- html.div([attribute.class("card-body")], children),
1917
- ])
1918
- }
1919
-
1920
- // 재사용 가능한 빈 상태 컴포넌트
1921
- fn empty_state(message: String) -> ReactElement {
1922
- html.div([attribute.class("empty-state")], [
1923
- html.p_([react.text(message)]),
1924
- ])
1925
- }
1926
-
1927
- // 조합하여 사용
1928
- pub fn dashboard(props) -> ReactElement {
1929
- html.div([attribute.class("dashboard")], [
1930
- card("사용자 목록", [
1931
- // 리스트 내용...
1932
- ]),
1933
- card("최근 활동", [
1934
- empty_state("아직 활동이 없습니다."),
1935
- ]),
1936
- ])
1937
- }
1938
- ```
1939
-
1940
- ### 5.5 SVG 아이콘 컴포넌트
1941
-
1942
- ```gleam
1943
- import glendix/react.{type ReactElement}
1944
- import glendix/react/attribute
1945
- import glendix/react/svg
1946
- import glendix/react/svg_attribute as sa
1947
-
1948
- fn check_icon(size: String) -> ReactElement {
1949
- svg.svg(
1950
- [
1951
- sa.view_box("0 0 24 24"),
1952
- attribute.width(size),
1953
- attribute.height(size),
1954
- sa.fill("none"),
1955
- sa.stroke("currentColor"),
1956
- sa.stroke_width("2"),
1957
- sa.stroke_linecap("round"),
1958
- sa.stroke_linejoin("round"),
1959
- ],
1960
- [svg.path([sa.d("M20 6L9 17l-5-5")], [])],
1961
- )
1962
- }
1963
- ```
1964
-
1965
- ### 5.6 Marketplace 위젯 다운로드
1966
-
1967
- Mendix Marketplace에서 위젯(.mpk)을 인터랙티브하게 검색하고 다운로드할 수 있습니다. 다운로드 완료 후 바인딩 `.gleam` 파일이 자동 생성되어, 별도의 수동 설정 없이 바로 사용할 수 있습니다.
1968
-
1969
- #### 사전 준비
1970
-
1971
- `.env` 파일에 Mendix Personal Access Token을 설정합니다:
1972
-
1973
- ```
1974
- MENDIX_PAT=your_personal_access_token
1975
- ```
1976
-
1977
- > PAT는 [Mendix Developer Settings](https://user-settings.mendix.com/link/developersettings)에서 **Personal Access Tokens** 섹션의 **New Token**을 클릭하여 발급합니다.
1978
- > 필요한 scope: `mx:marketplace-content:read`
1979
-
1980
- #### 실행
1981
-
1982
- ```bash
1983
- gleam run -m glendix/marketplace
1984
- ```
1985
-
1986
- #### 인터랙티브 TUI
1987
-
1988
- 실행하면 Content API(`GET /content`)로 위젯 목록을 로드하고, 인터랙티브 TUI가 표시됩니다:
1989
-
1990
- ```
1991
- ── 페이지 1/5+ ──
1992
-
1993
- [0] Star Rating (54611) v3.2.2 — Mendix
1994
- [1] Switch (50324) v4.0.0 — Mendix
1995
- [2] Progress Bar (48019) v3.1.0 — Mendix
1996
- ...
1997
-
1998
- 번호: 다운로드 | 검색어: 이름 검색 | n: 다음 | p: 이전 | r: 초기화 | q: 종료
1999
-
2000
- >
2001
- ```
2002
-
2003
- **주요 명령어:**
2004
-
2005
- | 입력 | 동작 |
2006
- |------|------|
2007
- | `0` | 0번 위젯 다운로드 |
2008
- | `0,1,3` | 여러 위젯 동시 다운로드 (쉼표 구분) |
2009
- | `star` | 이름/퍼블리셔로 검색 필터링 |
2010
- | `n` / `p` | 다음/이전 페이지 |
2011
- | `r` | 검색 초기화 (전체 목록 복귀) |
2012
- | `q` | 종료 |
2013
-
2014
- #### 버전 선택
2015
-
2016
- 위젯을 선택하면 버전 목록이 표시됩니다. Pluggable/Classic 타입이 자동 구분됩니다:
2017
-
2018
- ```
2019
- Star Rating — 버전 선택:
2020
-
2021
- [0] v3.2.2 (2024-01-15) (Mendix ≥9.24.0) [Pluggable] ← 기본
2022
- [1] v3.1.0 (2023-08-20) (Mendix ≥9.18.0) [Pluggable]
2023
- [2] v2.5.1 (2022-03-10) (Mendix ≥8.0.0) [Classic]
2024
-
2025
- 버전 번호 (Enter=최신):
2026
- ```
2027
-
2028
- Enter를 누르면 최신 버전이 다운로드됩니다.
2029
-
2030
- #### 동작 흐름
2031
-
2032
- 1. **첫 배치 로드** — Content API에서 첫 40개 아이템을 직접 로드하여 즉시 표시
2033
- 2. **백그라운드 로드** — 나머지 아이템을 별도 프로세스에서 비동기 로드 (`.marketplace-cache/`에 캐시)
2034
- 3. **위젯 선택 시** — Playwright(headless chromium)로 Marketplace 페이지에서 S3 다운로드 URL 추출
2035
- 4. **다운로드** — S3에서 `.mpk` 파일을 `widgets/` 디렉토리에 저장
2036
- 5. **바인딩 생성** — `cmd.generate_widget_bindings()`가 자동 호출되어 `src/widgets/`에 바인딩 `.gleam` 파일 생성
2037
-
2038
- > 버전 정보 조회에 Playwright를 사용하므로, 첫 다운로드 시 브라우저 로그인이 필요합니다. 세션은 `.marketplace-cache/session.json`에 저장되어 이후 재사용됩니다.
2039
-
2040
- #### 다운로드 사용
2041
-
2042
- 다운로드된 위젯은 자동으로 바인딩이 생성됩니다. Pluggable 위젯과 Classic 위젯은 각각 다른 패턴으로 사용합니다:
2043
-
2044
- **Pluggable 위젯** (`glendix/widget` 사용):
2045
-
2046
- ```gleam
2047
- // src/widgets/star_rating.gleam (자동 생성)
2048
- import glendix/mendix
2049
- import glendix/react.{type JsProps, type ReactElement}
2050
- import glendix/react/attribute
2051
- import glendix/widget
2052
-
2053
- pub fn render(props: JsProps) -> ReactElement {
2054
- let rate_attribute = mendix.get_prop_required(props, "rateAttribute")
2055
- let comp = widget.component("StarRating")
2056
- react.component_el(
2057
- comp,
2058
- [attribute.attribute("rateAttribute", rate_attribute)],
2059
- [],
2060
- )
2061
- }
2062
- ```
2063
-
2064
- **Classic (Dojo) 위젯** (`glendix/classic` 사용):
2065
-
2066
- ```gleam
2067
- // src/widgets/camera_widget.gleam (자동 생성)
2068
- import gleam/dynamic
2069
- import glendix/classic
2070
- import glendix/mendix
2071
- import glendix/react.{type JsProps, type ReactElement}
2072
-
2073
- pub fn render(props: JsProps) -> ReactElement {
2074
- let mf_to_execute = mendix.get_prop_required(props, "mfToExecute")
2075
- classic.render("CameraWidget.widget.CameraWidget", [
2076
- #("mfToExecute", dynamic.from(mf_to_execute)),
2077
- ])
2078
- }
2079
- ```
2080
-
2081
- **위젯에서 import:**
2082
-
2083
- ```gleam
2084
- import widgets/star_rating
2085
- import widgets/camera_widget
2086
-
2087
- // 컴포넌트 내부에서
2088
- star_rating.render(props)
2089
- camera_widget.render(props)
2090
- ```
2091
-
2092
- 생성된 `src/widgets/*.gleam` 파일은 자유롭게 수정할 수 있으며, 이미 존재하는 파일은 재생성 시 덮어쓰지 않습니다.
2093
-
2094
- ---
2095
-
2096
- ## 6. 트러블슈팅
2097
-
2098
- ### 빌드 에러
2099
-
2100
- | 문제 | 원인 | 해결 |
2101
- |---|---|---|
2102
- | `gleam build` 실패: glendix를 찾을 수 없음 | `gleam.toml`의 경로가 잘못됨 | `path = "../glendix"` 경로 확인 |
2103
- | `react is not defined` | peer dependency 미설치 | `gleam run -m glendix/install` |
2104
- | `Big is not a constructor` | big.js 미설치 | `gleam run -m glendix/install` |
2105
-
2106
- ### 런타임 에러
2107
-
2108
- | 문제 | 원인 | 해결 |
2109
- |---|---|---|
2110
- | `Cannot read property of undefined` | 존재하지 않는 prop 접근 | `get_prop` (Option) 대신 `get_prop_required` 사용 시 prop 이름 확인 |
2111
- | `set_value` 호출 시 에러 | read_only 상태에서 값 설정 | `ev.is_editable(attr)` 확인 후 설정 |
2112
- | Hook 순서 에러 | 조건부로 Hook 호출 | Hook은 항상 동일한 순서로 호출해야 함 (React Rules of Hooks) |
2113
- | `바인딩이 생성되지 않았습니다` | `binding_ffi.mjs`가 스텁 상태 | `gleam run -m glendix/install` 실행 |
2114
- | `위젯 바인딩이 생성되지 않았습니다` | `widget_ffi.mjs`가 스텁 상태 | `widgets/` 디렉토리에 `.mpk` 배치 `gleam run -m glendix/install` 실행 |
2115
- | `위젯 바인딩에 등록되지 않은 위젯` | 해당 `.mpk`가 `widgets/`에 없음 | `.mpk` 파일 배치 후 재설치 |
2116
- | `could not be resolved – treating it as an external dependency` | `bindings.json`에 등록한 패키지가 `node_modules`에 없음 | `npm install <패키지명>` 등으로 설치 후 재빌드 |
2117
- | `바인딩에 등록되지 않은 모듈` | `bindings.json`에 해당 패키지 미등록 | `bindings.json`에 패키지와 컴포넌트 추가 후 재설치 |
2118
- | `모듈에 없는 컴포넌트` | `bindings.json`의 `components`에 해당 컴포넌트 미등록 | `components` 배열에 추가 재설치 |
2119
- | `.env 파일에 MENDIX_PAT가 필요합니다` | marketplace 실행 시 PAT 미설정 | `.env`에 `MENDIX_PAT=...` 추가 (scope: `mx:marketplace-content:read`) — [Developer Settings](https://user-settings.mendix.com/link/developersettings)에서 발급 |
2120
- | `인증 실패 — MENDIX_PAT를 확인하세요` | PAT가 잘못되었거나 만료됨 | [Developer Settings](https://user-settings.mendix.com/link/developersettings)에서 새 PAT 발급 |
2121
- | `위젯을 불러올 수 없습니다` | Content API 접근 실패 | 네트워크 및 PAT 확인 |
2122
- | `Playwright 오류` | chromium 미설치 또는 세션 만료 | `npx playwright install chromium` 실행, 또는 브라우저 재로그인 |
2123
- | `저장된 세션이 만료되었습니다` | Mendix 로그인 세션 만료 | 브라우저 로그인 팝업에서 재로그인 |
2124
-
2125
- ### 일반적인 실수
2126
-
2127
- **1. Hook을 조건부로 호출하지 마세요:**
2128
-
2129
- ```gleam
2130
- // 잘못된 예
2131
- pub fn widget(props) {
2132
- case mendix.get_prop(props, "attr") {
2133
- Some(attr) -> {
2134
- let #(count, set_count) = hook.use_state(0) // 조건 안에서 Hook!
2135
- // ...
2136
- }
2137
- None -> react.none()
2138
- }
2139
- }
2140
-
2141
- // 올바른 예
2142
- pub fn widget(props) {
2143
- let #(count, set_count) = hook.use_state(0) // 항상 최상위에서 호출
2144
-
2145
- case mendix.get_prop(props, "attr") {
2146
- Some(attr) -> // count 사용...
2147
- None -> react.none()
2148
- }
2149
- }
2150
- ```
2151
-
2152
- **2. 리스트 렌더링에서 key를 빠뜨리지 마세요:**
2153
-
2154
- ```gleam
2155
- // key가 있어야 React가 효율적으로 업데이트합니다
2156
- list.map(items, fn(item) {
2157
- html.div([attribute.key(mendix.object_id(item))], [
2158
- // ...
2159
- ])
2160
- })
2161
- ```
2162
-
2163
- **3. 월(month) 변환을 직접 하지 마세요:**
2164
-
2165
- ```gleam
2166
- // glendix/mendix/date가 자동으로 1-based ↔ 0-based 변환합니다
2167
- let month = date.month(my_date) // 1~12 (Gleam 기준, 변환 불필요)
2168
- ```
2169
-
2170
- **4. 외부 React 컴포넌트용 `.mjs` 파일을 직접 작성하지 마세요:**
2171
-
2172
- ```gleam
2173
- // 잘못된 방법 — 수동 FFI 작성
2174
- // recharts_ffi.mjs를 만들고 @external로 연결하는 것
2175
-
2176
- // 올바른 방법 — bindings.json + glendix/binding 사용
2177
- import glendix/binding
2178
- let rc = binding.module("recharts")
2179
- react.component_el(binding.resolve(rc, "PieChart"), attrs, children)
2180
- react.void_component_el(binding.resolve(rc, "Tooltip"), attrs)
2181
- ```
2182
-
2183
- **5. `.mpk` 위젯용 `.mjs` 파일을 직접 작성하지 마세요:**
2184
-
2185
- ```gleam
2186
- // 잘못된 방법 — 수동 FFI 작성
2187
-
2188
- // 올바른 방법 widgets/ 디렉토리 + glendix/widget 사용
2189
- import glendix/widget
2190
- let switch_comp = widget.component("Switch")
2191
- react.component_el(switch_comp, attrs, children)
2192
- ```
2193
-
2194
- **6. `binding.resolve()`에서 컴포넌트 이름을 snake_case로 바꾸지 마세요:**
2195
-
2196
- ```gleam
2197
- // 잘못된
2198
- binding.resolve(m(), "pie_chart")
2199
-
2200
- // 올바른 JavaScript 원본 이름(PascalCase) 그대로 사용
2201
- binding.resolve(m(), "PieChart")
2202
- ```
2203
-
2204
- **7. `react.none()` 대신 빈 문자열이나 빈 리스트를 사용하지 마세요:**
2205
-
2206
- ```gleam
2207
- // 잘못된
2208
- react.text("") // 빈 텍스트 노드 생성
2209
- react.fragment([]) // 빈 Fragment 생성
2210
-
2211
- // 올바른
2212
- react.none() // React null 반환
2213
- ```
2214
-
2215
- ---
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
+ gleam run -m glendix/install
49
+ ```
50
+
51
+ > `glendix/install`은 패키지 매니저를 자동 감지하여 의존성을 설치하고, `bindings.json`이 있으면 외부 React 컴포넌트 바인딩을, `widgets/` 디렉토리에 `.mpk` 파일이 있으면 위젯 바인딩도 자동 생성합니다.
52
+
53
+ #### 4) 빌드 확인
54
+
55
+ ```bash
56
+ gleam build
57
+ ```
58
+
59
+ ### 1.3 첫 번째 위젯 만들기
60
+
61
+ `src/my_widget.gleam` 파일을 생성합니다:
62
+
63
+ ```gleam
64
+ import glendix/mendix
65
+ import glendix/react.{type JsProps, type ReactElement}
66
+ import glendix/react/attribute
67
+ import glendix/react/html
68
+
69
+ pub fn widget(props: JsProps) -> ReactElement {
70
+ let greeting = mendix.get_string_prop(props, "greetingText")
71
+
72
+ html.div([attribute.class("my-widget")], [
73
+ html.h1([attribute.class("title")], [react.text(greeting)]),
74
+ html.p_([react.text("glendix로 만든 첫 번째 위젯입니다!")]),
75
+ ])
76
+ }
77
+ ```
78
+
79
+ 이것이 Mendix Pluggable Widget의 전부입니다 — `fn(JsProps) -> ReactElement`.
80
+
81
+ ---
82
+
83
+ ## 2. 핵심 개념
84
+
85
+ ### 2.1 위젯 함수 시그니처
86
+
87
+ 모든 Mendix Pluggable Widget은 하나의 함수입니다:
88
+
89
+ ```gleam
90
+ pub fn widget(props: JsProps) -> ReactElement
91
+ ```
92
+
93
+ - `JsProps`: Mendix가 위젯에 전달하는 프로퍼티 객체 (opaque 타입)
94
+ - `ReactElement`: React가 렌더링할 수 있는 요소
95
+
96
+ ### 2.2 Opaque 타입
97
+
98
+ glendix의 핵심 설계 원칙은 **opaque 타입을 통한 타입 안전성**입니다.
99
+
100
+ ```gleam
101
+ // 이 타입들은 내부 구현이 숨겨져 있어 잘못된 접근을 컴파일 타임에 차단합니다
102
+ ReactElement // React 요소
103
+ JsProps // Mendix 프로퍼티 객체
104
+ Attribute // HTML/React 속성
105
+ EditableValue // Mendix 편집 가능한 값
106
+ ActionValue // Mendix 액션
107
+ ListValue // Mendix 리스트 데이터
108
+ // ... 등등
109
+ ```
110
+
111
+ 각 opaque 타입은 반드시 해당 모듈이 제공하는 접근자 함수를 통해서만 사용할 수 있습니다. 이를 통해 JS 런타임 에러를 Gleam 컴파일 타임 에러로 전환합니다.
112
+
113
+ ### 2.3 undefined ↔ Option 자동 변환
114
+
115
+ FFI 경계에서 JavaScript의 `undefined`/`null`은 자동으로 변환됩니다:
116
+
117
+ | JavaScript | Gleam |
118
+ |---|---|
119
+ | `undefined` / `null` | `None` |
120
+ | 값 존재 | `Some(value)` |
121
+
122
+ ```gleam
123
+ // Mendix props에서 값 가져오기
124
+ case mendix.get_prop(props, "myAttr") {
125
+ Some(attr) -> // 값이 설정되어 있음
126
+ None -> // 값이 없음 (undefined)
127
+ }
128
+ ```
129
+
130
+ ### 2.4 Attribute 리스트 API
131
+
132
+ HTML 속성은 `[attribute.xxx(), event.xxx()]` 선언적 리스트 패턴으로 구성합니다:
133
+
134
+ ```gleam
135
+ import glendix/react/attribute
136
+ import glendix/react/event
137
+
138
+ html.button(
139
+ [
140
+ attribute.class("btn btn-primary"),
141
+ attribute.type_("submit"),
142
+ attribute.disabled(False),
143
+ event.on_click(fn(_event) { Nil }),
144
+ ],
145
+ [react.text("Submit")],
146
+ )
147
+ ```
148
+
149
+ 조건부 속성은 `attribute.none()`으로 처리합니다:
150
+
151
+ ```gleam
152
+ html.input([
153
+ attribute.class("input"),
154
+ case is_error {
155
+ True -> attribute.class("input-error")
156
+ False -> attribute.none()
157
+ },
158
+ ])
159
+ ```
160
+
161
+ 여러 `attribute.class()` 호출 시 자동으로 공백 구분 병합됩니다.
162
+
163
+ ---
164
+
165
+ ## 3. React 바인딩
166
+
167
+ ### 3.1 엘리먼트 생성
168
+
169
+ `glendix/react` 모듈은 React 엘리먼트를 생성하는 핵심 함수들을 제공합니다.
170
+
171
+ #### 기본 엘리먼트
172
+
173
+ ```gleam
174
+ import glendix/react
175
+ import glendix/react/attribute
176
+
177
+ // Attribute 리스트가 있는 엘리먼트
178
+ react.element("div", [attribute.class("container")], [
179
+ react.text("Hello"),
180
+ ])
181
+
182
+ // 속성 없이 간단하게
183
+ react.element_("div", [
184
+ react.text("Hello"),
185
+ ])
186
+
187
+ // Self-closing 엘리먼트 (input, img, br 등)
188
+ react.void_element("input", [attribute.type_("text")])
189
+ ```
190
+
191
+ #### 텍스트 노드
192
+
193
+ ```gleam
194
+ react.text("안녕하세요")
195
+ react.text("Count: " <> int.to_string(count))
196
+ ```
197
+
198
+ #### Fragment
199
+
200
+ ```gleam
201
+ // 기본 Fragment
202
+ react.fragment([
203
+ html.h1([attribute.class("title")], [react.text("제목")]),
204
+ html.p_([react.text("내용")]),
205
+ ])
206
+
207
+ // 키가 있는 Fragment (리스트 렌더링에서 사용)
208
+ react.keyed_fragment("unique-key", [
209
+ html.li_([react.text("아이템")]),
210
+ ])
211
+ ```
212
+
213
+ #### 아무것도 렌더링하지 않기
214
+
215
+ ```gleam
216
+ react.none() // React null 반환
217
+ ```
218
+
219
+ #### 외부 React 컴포넌트 사용
220
+
221
+ `glendix/binding` 모듈로 외부 React 라이브러리를 **`.mjs` 없이** 사용합니다.
222
+
223
+ **1단계: `bindings.json` 작성** (프로젝트 루트)
224
+
225
+ ```json
226
+ {
227
+ "recharts": {
228
+ "components": ["PieChart", "Pie", "Cell", "LineChart", "Line",
229
+ "XAxis", "YAxis", "CartesianGrid", "Tooltip", "Legend",
230
+ "ResponsiveContainer"]
231
+ }
232
+ }
233
+ ```
234
+
235
+ **2단계: 패키지 설치** — `bindings.json`에 등록한 패키지는 위젯 프로젝트의 `node_modules`에 설치되어 있어야 합니다. 생성된 `binding_ffi.mjs`가 해당 패키지를 직접 import하므로, Rollup이 번들링할 때 resolve할 수 있어야 합니다.
236
+
237
+ ```bash
238
+ # 사용 중인 패키지 매니저에 맞게 설치
239
+ npm install recharts
240
+ # 또는
241
+ pnpm add recharts
242
+ # 또는
243
+ yarn add recharts
244
+ # 또는
245
+ bun add recharts
246
+ ```
247
+
248
+ **3단계: `gleam run -m glendix/install`** 실행 (바인딩 자동 생성)
249
+
250
+ **4단계: 순수 Gleam 래퍼 모듈 작성** (편의용, 선택사항)
251
+
252
+ html.gleam과 동일한 호출 패턴으로 작성하면 일관된 API를 제공할 수 있습니다:
253
+
254
+ ```gleam
255
+ // src/chart/recharts.gleam — 순수 Gleam, FFI 없음!
256
+ import glendix/binding
257
+ import glendix/react.{type ReactElement}
258
+ import glendix/react/attribute.{type Attribute}
259
+
260
+ fn m() { binding.module("recharts") }
261
+
262
+ // attrs + children 컴포넌트 (html.div 패턴)
263
+ pub fn pie_chart(attrs: List(Attribute), children: List(ReactElement)) -> ReactElement {
264
+ react.component_el(binding.resolve(m(), "PieChart"), attrs, children)
265
+ }
266
+
267
+ pub fn pie(attrs: List(Attribute), children: List(ReactElement)) -> ReactElement {
268
+ react.component_el(binding.resolve(m(), "Pie"), attrs, children)
269
+ }
270
+
271
+ pub fn responsive_container(attrs: List(Attribute), children: List(ReactElement)) -> ReactElement {
272
+ react.component_el(binding.resolve(m(), "ResponsiveContainer"), attrs, children)
273
+ }
274
+
275
+ // children 없는 컴포넌트 (html.input 패턴)
276
+ pub fn cell(attrs: List(Attribute)) -> ReactElement {
277
+ react.void_component_el(binding.resolve(m(), "Cell"), attrs)
278
+ }
279
+
280
+ pub fn tooltip(attrs: List(Attribute)) -> ReactElement {
281
+ react.void_component_el(binding.resolve(m(), "Tooltip"), attrs)
282
+ }
283
+
284
+ pub fn legend(attrs: List(Attribute)) -> ReactElement {
285
+ react.void_component_el(binding.resolve(m(), "Legend"), attrs)
286
+ }
287
+ ```
288
+
289
+ **5단계: 위젯에서 사용**
290
+
291
+ html.gleam과 동일한 호출 구조입니다:
292
+
293
+ ```gleam
294
+ import chart/recharts
295
+ import glendix/react
296
+ import glendix/react/attribute
297
+
298
+ pub fn my_pie_chart(data) -> react.ReactElement {
299
+ recharts.responsive_container(
300
+ [attribute.attribute("width", 400), attribute.attribute("height", 300)],
301
+ [
302
+ recharts.pie_chart([], [
303
+ recharts.pie(
304
+ [attribute.attribute("data", data), attribute.attribute("dataKey", "value")],
305
+ [],
306
+ ),
307
+ recharts.tooltip([]),
308
+ recharts.legend([]),
309
+ ]),
310
+ ],
311
+ )
312
+ }
313
+ ```
314
+
315
+ 래퍼 모듈 없이 직접 사용하는 것도 가능합니다:
316
+
317
+ ```gleam
318
+ import glendix/binding
319
+
320
+ let rc = binding.module("recharts")
321
+ react.component_el(binding.resolve(rc, "PieChart"), attrs, children)
322
+ react.void_component_el(binding.resolve(rc, "Tooltip"), attrs)
323
+ ```
324
+
325
+ #### .mpk 위젯 컴포넌트 사용
326
+
327
+ `glendix/widget` 모듈로 `widgets/` 디렉토리의 `.mpk` 파일(Mendix 위젯 빌드 결과물)을 React 컴포넌트로 사용합니다. 다른 위젯 안에서 기존 Mendix 위젯을 렌더링할 때 유용합니다.
328
+
329
+ **1단계: `.mpk` 파일 배치**
330
+
331
+ 프로젝트 루트에 `widgets/` 디렉토리를 만들고 `.mpk` 파일을 복사합니다:
332
+
333
+ ```
334
+ my_widget/
335
+ ├── widgets/
336
+ │ ├── Switch.mpk
337
+ │ └── Badge.mpk
338
+ ├── src/
339
+ ├── gleam.toml
340
+ └── ...
341
+ ```
342
+
343
+ **2단계: `gleam run -m glendix/install`** 실행 (위젯 바인딩 자동 생성)
344
+
345
+ install 시 두 가지가 자동 수행됩니다:
346
+ - `.mpk` 내부의 `.mjs`와 `.css`가 추출되고, `widget_ffi.mjs`가 생성됩니다
347
+ - `.mpk` XML의 `<property>` 정의가 부모 위젯 XML(`src/{WidgetName}.xml`)에 `<propertyGroup caption="{위젯명}">` 으로 자동 주입됩니다 (동일 caption이 이미 있으면 건너뜀)
348
+
349
+ 예를 들어 `Switch.mpk`를 설치하면, 부모 위젯 XML에 다음이 자동 추가됩니다:
350
+
351
+ ```xml
352
+ <propertyGroup caption="Switch">
353
+ <property key="booleanAttribute" type="attribute">
354
+ <caption>Boolean attribute</caption>
355
+ <description>Attribute to toggle</description>
356
+ <attributeTypes>
357
+ <attributeType name="Boolean" />
358
+ </attributeTypes>
359
+ </property>
360
+ <property key="action" type="action" required="false">
361
+ <caption>On change</caption>
362
+ <description>Action to be performed when the switch is toggled</description>
363
+ </property>
364
+ </propertyGroup>
365
+ ```
366
+
367
+ **3단계: Gleam 코드에서 사용**
368
+
369
+ ```gleam
370
+ import glendix/mendix
371
+ import glendix/widget
372
+ import glendix/react
373
+ import glendix/react/attribute
374
+
375
+ // props에서 자동 주입된 속성을 읽어 위젯에 전달
376
+ let boolean_attr = mendix.get_prop_required(props, "booleanAttribute")
377
+ let action = mendix.get_prop_required(props, "action")
378
+
379
+ // widgets/Switch.mpk의 Switch 컴포넌트 사용
380
+ let switch_comp = widget.component("Switch")
381
+ react.component_el(switch_comp, [
382
+ attribute.attribute("booleanAttribute", boolean_attr),
383
+ attribute.attribute("action", action),
384
+ ], [])
385
+ ```
386
+
387
+ 위젯의 Props는 기존 `attribute.attribute(key, value)` 범용 함수로 전달합니다. 위젯 이름은 `.mpk` 내부 XML의 `<name>` 태그 값(PascalCase)을, property key는 `.mpk` XML의 원본 key를 그대로 사용합니다.
388
+
389
+ > `binding` 모듈과 달리 `widget` 모듈은 1 mpk = 1 컴포넌트이므로 `module` + `resolve` 2단계 없이 `component("Name")` 한 번에 가져옵니다.
390
+
391
+ ### 3.2 HTML 속성
392
+
393
+ `glendix/react/attribute` 모듈은 90+ HTML 속성 함수를 제공합니다.
394
+
395
+ #### 범용 속성
396
+
397
+ ```gleam
398
+ import glendix/react/attribute
399
+
400
+ // escape hatch — 임의의 속성 설정
401
+ attribute.attribute("data-custom", "value")
402
+
403
+ // aria-* 속성
404
+ attribute.aria("label", "닫기 버튼")
405
+
406
+ // data-* 속성
407
+ attribute.data("testid", "my-element")
408
+ ```
409
+
410
+ #### 자주 쓰는 속성
411
+
412
+ ```gleam
413
+ attribute.class("btn btn-primary") // className (자동 병합)
414
+ attribute.classes(["btn", "large"]) // 여러 클래스 공백 결합
415
+ attribute.id("main-card")
416
+ attribute.key("item-1") // React key
417
+ attribute.ref(my_ref) // React ref
418
+ attribute.ref_(fn(el) { Nil }) // callback ref
419
+ ```
420
+
421
+ #### 폼 속성
422
+
423
+ ```gleam
424
+ attribute.type_("text")
425
+ attribute.value("입력값")
426
+ attribute.placeholder("입력하세요")
427
+ attribute.name("username")
428
+ attribute.disabled(True)
429
+ attribute.checked(True)
430
+ attribute.readonly(True)
431
+ attribute.required(True)
432
+ attribute.max_length(100)
433
+ attribute.min_length(3)
434
+ attribute.pattern("[0-9]+")
435
+ attribute.autocomplete("email")
436
+ attribute.autofocus(True)
437
+ ```
438
+
439
+ #### 인라인 스타일
440
+
441
+ ```gleam
442
+ // CSS 속성명 자동 camelCase 변환
443
+ attribute.style([
444
+ #("background-color", "#f0f0f0"),
445
+ #("padding", "16px"),
446
+ #("border-radius", "8px"),
447
+ ])
448
+ ```
449
+
450
+ #### 미디어/리소스 속성
451
+
452
+ ```gleam
453
+ attribute.src("image.png")
454
+ attribute.alt("설명")
455
+ attribute.loading("lazy") // 지연 로딩
456
+ attribute.fetch_priority("high") // 로딩 우선순위
457
+ attribute.cross_origin("anonymous")
458
+ attribute.srcset("img-2x.png 2x")
459
+ attribute.sizes("(max-width: 600px) 100vw")
460
+ ```
461
+
462
+ #### 모바일/국제화 속성 (Round 3)
463
+
464
+ ```gleam
465
+ attribute.input_mode("numeric") // 가상 키보드
466
+ attribute.enter_key_hint("search") // Enter 키 동작
467
+ attribute.auto_capitalize("words") // 대문자 변환
468
+ attribute.capture_("environment") // 카메라 선택
469
+ ```
470
+
471
+ #### Popover API (Round 3)
472
+
473
+ ```gleam
474
+ html.button(
475
+ [
476
+ attribute.popover_target("my-popover"),
477
+ attribute.popover_target_action("toggle"),
478
+ ],
479
+ [react.text("열기")],
480
+ )
481
+
482
+ html.div(
483
+ [attribute.id("my-popover"), attribute.popover("auto")],
484
+ [react.text("팝오버 내용")],
485
+ )
486
+ ```
487
+
488
+ ### 3.3 이벤트 핸들러
489
+
490
+ `glendix/react/event` 모듈은 15개 이벤트 타입과 148+ 핸들러를 제공합니다.
491
+
492
+ #### 이벤트 타입
493
+
494
+ | 타입 | 용도 |
495
+ |---|---|
496
+ | `Event` | 기본/미디어/UI 이벤트 |
497
+ | `MouseEvent` | 클릭, 마우스 이벤트 |
498
+ | `ChangeEvent` | input 변경 이벤트 |
499
+ | `KeyboardEvent` | 키보드 이벤트 |
500
+ | `FormEvent` | 폼 제출 이벤트 |
501
+ | `FocusEvent` | 포커스/블러 이벤트 |
502
+ | `InputEvent` | 입력/beforeinput 이벤트 |
503
+ | `PointerEvent` | 포인터 이벤트 |
504
+ | `DragEvent` | 드래그 앤 드롭 이벤트 |
505
+ | `ClipboardEvent` | 복사/잘라내기/붙여넣기 |
506
+ | `TouchEvent` | 터치 이벤트 |
507
+ | `WheelEvent` | 휠 스크롤 이벤트 |
508
+ | `AnimationEvent` | CSS 애니메이션 이벤트 |
509
+ | `TransitionEvent` | CSS 트랜지션 이벤트 |
510
+ | `CompositionEvent` | CJK/IME 입력 이벤트 |
511
+
512
+ #### 핸들러 사용
513
+
514
+ ```gleam
515
+ import glendix/react/event
516
+
517
+ // 마우스 이벤트
518
+ event.on_click(fn(e) { handle_click(e) })
519
+ event.on_double_click(fn(e) { Nil })
520
+ event.on_context_menu(fn(e) { Nil })
521
+ event.on_mouse_enter(fn(e) { Nil })
522
+
523
+ // 키보드 이벤트
524
+ event.on_key_down(fn(e) {
525
+ case event.key(e) {
526
+ "Enter" -> submit()
527
+ "Escape" -> cancel()
528
+ _ -> Nil
529
+ }
530
+ })
531
+
532
+ // 폼/입력 이벤트
533
+ event.on_change(fn(e) { set_name(event.target_value(e)) })
534
+ event.on_input(fn(e) { Nil })
535
+ event.on_submit(fn(e) {
536
+ event.prevent_default(e)
537
+ handle_submit()
538
+ })
539
+
540
+ // 포커스 이벤트
541
+ event.on_focus(fn(e) { Nil })
542
+ event.on_blur(fn(e) { Nil })
543
+
544
+ // 로드/에러 이벤트 (Round 3)
545
+ event.on_load(fn(e) { Nil }) // img/iframe/script 로드 완료
546
+ event.on_error(fn(e) { Nil }) // 리소스 로드 실패
547
+
548
+ // 입력 전 이벤트 (Round 3)
549
+ event.on_before_input(fn(e) { Nil }) // 입력 값 변경 전 필터링
550
+
551
+ // 미디어 이벤트
552
+ event.on_play(fn(e) { Nil })
553
+ event.on_pause(fn(e) { Nil })
554
+ event.on_time_update(fn(e) { Nil })
555
+ event.on_load_start(fn(e) { Nil }) // 미디어 로드 시작 (Round 3)
556
+
557
+ // 드래그 이벤트
558
+ event.on_drag_start(fn(e) { Nil })
559
+ event.on_drop(fn(e) { Nil })
560
+
561
+ // 컴포지션 이벤트 (한국어 입력 필수)
562
+ event.on_composition_start(fn(e) { Nil })
563
+ event.on_composition_end(fn(e) { Nil })
564
+
565
+ // 캡처 단계 (모든 핸들러에 _capture 접미사)
566
+ event.on_click_capture(fn(e) { Nil })
567
+ event.on_key_down_capture(fn(e) { Nil })
568
+
569
+ // 범용 이벤트 핸들러 (escape hatch)
570
+ event.on("onCustomEvent", fn(e) { Nil })
571
+ ```
572
+
573
+ #### 이벤트 접근자
574
+
575
+ ```gleam
576
+ // 공통
577
+ event.target(e) // 이벤트 대상 요소 (Dynamic)
578
+ event.current_target(e) // 핸들러가 등록된 요소 (Dynamic)
579
+ event.target_value(e) // input/textarea 값 (String)
580
+ event.prevent_default(e) // 기본 동작 방지
581
+ event.stop_propagation(e) // 전파 중지
582
+ event.bubbles(e) // 버블링 여부 (Bool)
583
+ event.cancelable(e) // 취소 가능 여부 (Bool)
584
+ event.is_trusted(e) // 사용자 발생 여부 (Bool)
585
+ event.time_stamp(e) // 타임스탬프 (Float)
586
+ event.native_event(e) // 네이티브 브라우저 이벤트 (Dynamic)
587
+ event.is_default_prevented(e)
588
+ event.is_propagation_stopped(e)
589
+
590
+ // 이벤트 유틸리티 (Round 3)
591
+ event.persist(e) // 이벤트 풀링 방지 (React 17+ 호환)
592
+ event.is_persistent(e) // 영속적 여부 (Bool)
593
+
594
+ // 마우스
595
+ event.client_x(e) // Float
596
+ event.client_y(e) // Float
597
+ event.page_x(e)
598
+ event.page_y(e)
599
+ event.offset_x(e)
600
+ event.offset_y(e)
601
+ event.screen_x(e)
602
+ event.screen_y(e)
603
+ event.movement_x(e)
604
+ event.movement_y(e)
605
+ event.button(e) // Int (0=좌, 1=중, 2=우)
606
+ event.buttons(e)
607
+ event.mouse_ctrl_key(e)
608
+ event.mouse_shift_key(e)
609
+ event.mouse_alt_key(e)
610
+ event.mouse_meta_key(e)
611
+ event.get_modifier_state(e, "Control")
612
+
613
+ // 키보드
614
+ event.key(e) // "Enter", "Escape" 등
615
+ event.code(e) // "KeyA", "Space" 등
616
+ event.ctrl_key(e)
617
+ event.shift_key(e)
618
+ event.alt_key(e)
619
+ event.meta_key(e)
620
+ event.repeat(e)
621
+
622
+ // 휠
623
+ event.delta_x(e)
624
+ event.delta_y(e)
625
+ event.delta_z(e)
626
+ event.delta_mode(e)
627
+
628
+ // 터치
629
+ event.touches(e) // Dynamic
630
+ event.changed_touches(e)
631
+ event.target_touches(e)
632
+
633
+ // 포인터
634
+ event.pointer_id(e)
635
+ event.pointer_type(e) // "mouse", "pen", "touch"
636
+ event.pressure(e)
637
+ event.tilt_x(e)
638
+ event.tilt_y(e)
639
+ event.pointer_width(e)
640
+ event.pointer_height(e)
641
+ event.is_primary(e)
642
+
643
+ // 애니메이션
644
+ event.animation_name(e)
645
+ event.animation_elapsed_time(e)
646
+ event.animation_pseudo_element(e)
647
+
648
+ // 트랜지션
649
+ event.property_name(e)
650
+ event.transition_elapsed_time(e)
651
+ event.transition_pseudo_element(e)
652
+
653
+ // 드래그
654
+ event.data_transfer(e) // Dynamic
655
+
656
+ // 포커스
657
+ event.focus_related_target(e) // Dynamic
658
+
659
+ // 컴포지션
660
+ event.composition_data(e) // String
661
+
662
+ // 입력
663
+ event.input_data(e) // String
664
+
665
+ // 클립보드
666
+ event.clipboard_data(e) // Dynamic
667
+ ```
668
+
669
+ ### 3.4 HTML 태그 함수
670
+
671
+ `glendix/react/html` 모듈은 75+ HTML 태그를 위한 편의 함수를 제공합니다. 순수 Gleam으로 구현되어 FFI가 없습니다.
672
+
673
+ ```gleam
674
+ import glendix/react/html
675
+ import glendix/react/attribute
676
+
677
+ // Attribute 리스트가 있는 버전
678
+ html.div([attribute.class("container")], children)
679
+ html.button([attribute.type_("submit"), event.on_click(handler)], children)
680
+ html.input([attribute.type_("text"), attribute.value(val)]) // void 엘리먼트
681
+
682
+ // Attribute 없는 버전 (언더스코어 접미사)
683
+ html.div_(children)
684
+ html.span_([react.text("텍스트")])
685
+ html.p_([react.text("문단")])
686
+ ```
687
+
688
+ #### 사용 가능한 태그 목록
689
+
690
+ | 카테고리 | 태그 |
691
+ |---|---|
692
+ | **컨테이너** | `div`, `span`, `section`, `main`, `header`, `footer`, `nav`, `aside`, `article` |
693
+ | **텍스트** | `p`, `h1`~`h6`, `strong`, `em`, `small`, `pre`, `code`, `kbd`, `samp`, `var_` |
694
+ | **리스트** | `ul`, `ol`, `li`, `dl`, `dt`, `dd` |
695
+ | **폼** | `form`, `button`, `label`, `select`, `option`, `textarea`, `fieldset`, `legend`, `datalist`, `optgroup`, `output` |
696
+ | **입력** | `input` (void) |
697
+ | **테이블** | `table`, `thead`, `tbody`, `tfoot`, `tr`, `td`, `th`, `colgroup`, `col` (void), `caption` |
698
+ | **링크/미디어** | `a`, `img` (void), `br` (void), `hr` (void), `video`, `audio`, `source` (void), `track` (void), `picture`, `canvas`, `iframe` (void) |
699
+ | **시맨틱** | `details`, `summary`, `dialog`, `figure`, `figcaption`, `blockquote`, `cite`, `abbr`, `mark`, `del`, `ins`, `sub`, `sup`, `time`, `address`, `meter`, `progress` (void), `search`, `hgroup` |
700
+ | **루비 주석** | `ruby`, `rt`, `rp` |
701
+ | **양방향 텍스트** | `bdi`, `bdo` |
702
+ | **기타** | `data_`, `map_`, `wbr` (void), `embed` (void), `area` (void) |
703
+
704
+ ### 3.5 SVG 요소
705
+
706
+ `glendix/react/svg` 모듈은 57개 SVG 요소를 제공합니다. 순수 Gleam, FFI 없음.
707
+
708
+ ```gleam
709
+ import glendix/react/svg
710
+ import glendix/react/svg_attribute as sa
711
+
712
+ svg.svg([sa.view_box("0 0 100 100"), sa.xmlns("http://www.w3.org/2000/svg")], [
713
+ svg.circle([sa.cx("50"), sa.cy("50"), sa.r("40"), sa.fill("blue")], []),
714
+ svg.text([sa.x("50"), sa.y("55"), sa.text_anchor("middle"), sa.fill("white")], [
715
+ react.text("Hello"),
716
+ ]),
717
+ ])
718
+ ```
719
+
720
+ #### SVG 요소 목록
721
+
722
+ | 카테고리 | 요소 |
723
+ |---|---|
724
+ | **컨테이너** | `svg`, `g`, `defs`, `symbol`, `use_`, `marker` |
725
+ | **도형** | `circle`, `ellipse`, `line`, `path`, `polygon`, `polyline`, `rect` |
726
+ | **텍스트** | `text`, `tspan`, `text_path` |
727
+ | **그래디언트/패턴** | `linear_gradient`, `radial_gradient`, `stop` (void), `pattern` |
728
+ | **필터** | `filter`, `fe_color_matrix`, `fe_composite`, `fe_flood` (void), `fe_gaussian_blur` (void), `fe_merge`, `fe_merge_node` (void), `fe_offset` (void), `fe_blend` (void), `fe_drop_shadow` (void) |
729
+ | **필터 프리미티브** | `fe_convolve_matrix`, `fe_diffuse_lighting`, `fe_displacement_map` (void), `fe_distant_light` (void), `fe_image` (void), `fe_morphology` (void), `fe_point_light` (void), `fe_specular_lighting`, `fe_spot_light` (void), `fe_tile`, `fe_turbulence` (void), `fe_func_r/g/b/a` (void), `fe_component_transfer` |
730
+ | **클리핑/마스킹** | `clip_path`, `mask` |
731
+ | **애니메이션** | `animate` (void), `animate_transform` (void), `set` (void), `mpath` (void) |
732
+ | **기타** | `foreign_object`, `image` (void), `title`, `desc`, `switch_` |
733
+
734
+ ### 3.6 SVG 속성
735
+
736
+ `glendix/react/svg_attribute` 모듈은 97+ SVG 전용 속성을 제공합니다. 순수 Gleam, FFI 없음.
737
+
738
+ ```gleam
739
+ import glendix/react/svg_attribute as sa
740
+
741
+ sa.view_box("0 0 100 100")
742
+ sa.fill("red")
743
+ sa.stroke("black")
744
+ sa.stroke_width("2")
745
+ sa.transform("rotate(45)")
746
+ sa.d("M10 10 L90 90") // path 데이터
747
+ ```
748
+
749
+ #### SVG 속성 목록
750
+
751
+ | 카테고리 | 속성 |
752
+ |---|---|
753
+ | **공통** | `view_box`, `xmlns`, `fill`, `stroke`, `stroke_width`, `stroke_linecap`, `stroke_linejoin`, `stroke_dasharray`, `stroke_dashoffset`, `stroke_opacity`, `fill_opacity`, `fill_rule`, `clip_rule`, `opacity`, `transform` |
754
+ | **좌표** | `x`, `y`, `x1`, `y1`, `x2`, `y2`, `cx`, `cy`, `r`, `rx`, `ry`, `dx`, `dy` |
755
+ | **도형** | `d`, `points`, `path_length` |
756
+ | **그래디언트** | `offset`, `stop_color`, `stop_opacity`, `gradient_units`, `gradient_transform`, `spread_method`, `fx`, `fy` |
757
+ | **텍스트** | `text_anchor`, `dominant_baseline`, `font_size`, `font_family`, `font_weight`, `letter_spacing`, `text_decoration`, `alignment_baseline`, `baseline_shift`, `writing_mode`, `text_rendering` |
758
+ | **참조** | `href`, `xlink_href` |
759
+ | **필터** | `filter_attr`, `in_`, `in2`, `result`, `std_deviation`, `flood_color`, `flood_opacity`, `values`, `mode`, `operator_`, `k1`~`k4`, `scale`, `x_channel_selector`, `y_channel_selector` |
760
+ | **마커** | `marker_start`, `marker_mid`, `marker_end`, `marker_height`, `marker_width`, `ref_x`, `ref_y`, `orient` |
761
+ | **패턴** | `pattern_units`, `pattern_transform`, `pattern_content_units` |
762
+ | **클리핑/마스킹** | `clip_path_attr`, `mask_attr`, `clip_path_units`, `mask_units`, `mask_content_units` |
763
+ | **렌더링** | `image_rendering`, `shape_rendering`, `color_interpolation`, `color_interpolation_filters` |
764
+ | **기타** | `preserve_aspect_ratio`, `overflow`, `cursor`, `visibility`, `pointer_events`, `color`, `display`, `enable_background`, `lighting_color` |
765
+
766
+ ### 3.7 React Hooks
767
+
768
+ `glendix/react/hook` 모듈은 37개 React Hooks를 제공합니다.
769
+
770
+ > Gleam의 튜플 `#(a, b)`은 JavaScript 배열 `[a, b]`과 동일하므로 React Hooks의 반환값과 직접 호환됩니다.
771
+
772
+ #### useState — 상태 관리
773
+
774
+ ```gleam
775
+ import glendix/react/hook
776
+
777
+ pub fn counter(_props) -> ReactElement {
778
+ let #(count, set_count) = hook.use_state(0)
779
+ let #(name, set_name) = hook.use_state("")
780
+
781
+ html.div_([
782
+ html.p_([react.text("Count: " <> int.to_string(count))]),
783
+ html.button(
784
+ [event.on_click(fn(_) { set_count(count + 1) })],
785
+ [react.text("+1")],
786
+ ),
787
+ ])
788
+ }
789
+
790
+ // 업데이터 함수 변형 (stale closure 방지)
791
+ let #(count, update_count) = hook.use_state_(0)
792
+ update_count(fn(prev) { prev + 1 })
793
+
794
+ // 지연 초기화 (비싼 초기값 계산 방지)
795
+ let #(data, set_data) = hook.use_lazy_state(fn() {
796
+ compute_expensive_initial_value()
797
+ })
798
+ ```
799
+
800
+ #### useEffect — 사이드 이펙트
801
+
802
+ ```gleam
803
+ // 의존성 배열 지정
804
+ hook.use_effect(fn() {
805
+ // count가 변경될 때마다 실행
806
+ Nil
807
+ }, [count])
808
+
809
+ // 마운트 시 한 번만 실행
810
+ hook.use_effect_once(fn() {
811
+ Nil
812
+ })
813
+
814
+ // 매 렌더링마다 실행
815
+ hook.use_effect_always(fn() {
816
+ Nil
817
+ })
818
+ ```
819
+
820
+ #### useEffect + 클린업
821
+
822
+ ```gleam
823
+ // 클린업 함수가 있는 effect
824
+ hook.use_effect_once_cleanup(fn() {
825
+ // 마운트 시 실행
826
+ let timer_id = set_interval(update, 1000)
827
+
828
+ // 클린업 함수 반환 (언마운트 시 실행)
829
+ fn() { clear_interval(timer_id) }
830
+ })
831
+
832
+ hook.use_effect_cleanup(fn() {
833
+ // effect 실행
834
+ fn() { /* 클린업 */ }
835
+ }, [dependency])
836
+
837
+ hook.use_effect_always_cleanup(fn() {
838
+ fn() { /* 매 렌더 후 클린업 */ }
839
+ })
840
+ ```
841
+
842
+ #### useLayoutEffect — 동기 레이아웃 이펙트
843
+
844
+ ```gleam
845
+ // DOM 변경 후 브라우저 페인트 전 동기 실행
846
+ hook.use_layout_effect(fn() {
847
+ // DOM 측정 로직
848
+ Nil
849
+ }, [some_dep])
850
+
851
+ // cleanup 변형도 동일 패턴
852
+ hook.use_layout_effect_once_cleanup(fn() {
853
+ fn() { Nil }
854
+ })
855
+ ```
856
+
857
+ #### useInsertionEffect — CSS-in-JS용
858
+
859
+ ```gleam
860
+ // DOM 변경 전 실행 (CSS-in-JS 라이브러리용)
861
+ hook.use_insertion_effect(fn() {
862
+ // 스타일 삽입
863
+ Nil
864
+ }, [theme])
865
+ ```
866
+
867
+ #### useMemo — 메모이제이션
868
+
869
+ ```gleam
870
+ // 값이 비용이 클 때 메모이제이션
871
+ let expensive_result = hook.use_memo(fn() {
872
+ compute_expensive_value(data)
873
+ }, [data])
874
+ ```
875
+
876
+ #### useCallback — 콜백 메모이제이션
877
+
878
+ ```gleam
879
+ // 콜백 함수 메모이제이션 (자식 컴포넌트에 전달할 때 유용)
880
+ let handle_click = hook.use_callback(fn(event) {
881
+ set_count(count + 1)
882
+ }, [count])
883
+ ```
884
+
885
+ #### useRef — 참조
886
+
887
+ ```gleam
888
+ let input_ref = hook.use_ref(Nil)
889
+
890
+ // ref 값 읽기
891
+ let current = hook.get_ref(input_ref)
892
+
893
+ // ref 값 쓰기
894
+ hook.set_ref(input_ref, new_value)
895
+
896
+ // DOM 요소에 연결
897
+ html.input([attribute.ref(input_ref)])
898
+ ```
899
+
900
+ #### useReducer — 리듀서 기반 상태
901
+
902
+ ```gleam
903
+ let #(state, dispatch) = hook.use_reducer(
904
+ fn(state, action) {
905
+ case action {
906
+ Increment -> State(..state, count: state.count + 1)
907
+ Decrement -> State(..state, count: state.count - 1)
908
+ }
909
+ },
910
+ initial_state,
911
+ )
912
+
913
+ dispatch(Increment)
914
+ ```
915
+
916
+ #### useContext — Context 값 읽기
917
+
918
+ ```gleam
919
+ let theme = hook.use_context(theme_context)
920
+ ```
921
+
922
+ #### useId — 고유 ID 생성
923
+
924
+ ```gleam
925
+ let id = hook.use_id() // SSR-safe 고유 ID
926
+ ```
927
+
928
+ #### useTransition — 비긴급 업데이트
929
+
930
+ ```gleam
931
+ let #(is_pending, start_transition) = hook.use_transition()
932
+
933
+ start_transition(fn() {
934
+ // 비긴급 상태 업데이트
935
+ set_search_results(filter(data, query))
936
+ })
937
+ ```
938
+
939
+ #### useDeferredValue — 값 지연
940
+
941
+ ```gleam
942
+ let deferred_query = hook.use_deferred_value(query)
943
+ ```
944
+
945
+ #### useOptimistic — 낙관적 UI (React 19)
946
+
947
+ ```gleam
948
+ // 간단한 형태
949
+ let #(optimistic_items, add_optimistic) = hook.use_optimistic(items)
950
+ add_optimistic(new_item)
951
+
952
+ // 리듀서 변형 — 업데이트 함수로 병합 로직 지정 (Round 3)
953
+ let #(optimistic_items, add_optimistic) = hook.use_optimistic_(
954
+ items,
955
+ fn(current, new_item) { list.append(current, [new_item]) },
956
+ )
957
+ add_optimistic(new_item)
958
+ ```
959
+
960
+ #### useImperativeHandle — ref 커스터마이징
961
+
962
+ ```gleam
963
+ hook.use_imperative_handle(ref, fn() {
964
+ // 부모에게 노출할 인터페이스
965
+ my_interface
966
+ }, [dep])
967
+ ```
968
+
969
+ #### useSyncExternalStore — 외부 스토어
970
+
971
+ ```gleam
972
+ let value = hook.use_sync_external_store(subscribe, get_snapshot)
973
+ ```
974
+
975
+ #### useDebugValue — DevTools 디버그
976
+
977
+ ```gleam
978
+ hook.use_debug_value(state)
979
+ hook.use_debug_value_(state, fn(s) { "State: " <> string.inspect(s) })
980
+ ```
981
+
982
+ ### 3.8 조건부 렌더링
983
+
984
+ ```gleam
985
+ import glendix/react
986
+
987
+ // Bool 기반 — 조건이 True일 때만 렌더링
988
+ react.when(is_logged_in, fn() {
989
+ html.div_([react.text("환영합니다!")])
990
+ })
991
+
992
+ // Option 기반 — Some일 때만 렌더링
993
+ react.when_some(maybe_user, fn(user) {
994
+ html.span_([react.text(user.name)])
995
+ })
996
+
997
+ // case 표현식으로 복잡한 조건 처리
998
+ case status {
999
+ Loading -> html.div_([react.text("로딩 중...")])
1000
+ Available -> html.div_([react.text("완료")])
1001
+ Unavailable -> react.none()
1002
+ }
1003
+ ```
1004
+
1005
+ ### 3.9 리스트 렌더링
1006
+
1007
+ ```gleam
1008
+ import gleam/list
1009
+
1010
+ // 리스트를 map하여 엘리먼트 생성
1011
+ let items = ["사과", "바나나", "체리"]
1012
+
1013
+ html.ul_(
1014
+ list.map(items, fn(item) {
1015
+ html.li([attribute.key(item)], [
1016
+ react.text(item),
1017
+ ])
1018
+ }),
1019
+ )
1020
+
1021
+ // 인덱스가 필요한 경우
1022
+ list.index_map(items, fn(item, idx) {
1023
+ html.li([attribute.key(int.to_string(idx))], [
1024
+ react.text(item),
1025
+ ])
1026
+ })
1027
+ ```
1028
+
1029
+ > 리스트 렌더링 시 항상 `attribute.key()`를 설정하세요. React의 reconciliation에 필요합니다.
1030
+
1031
+ ### 3.10 인라인 스타일
1032
+
1033
+ ```gleam
1034
+ import glendix/react/attribute
1035
+
1036
+ // 튜플 리스트로 스타일 지정 (CSS 속성명은 자동 camelCase 변환)
1037
+ html.div(
1038
+ [attribute.style([
1039
+ #("background-color", "#f0f0f0"),
1040
+ #("padding", "16px"),
1041
+ #("border-radius", "8px"),
1042
+ ])],
1043
+ children,
1044
+ )
1045
+ ```
1046
+
1047
+ ### 3.11 고급 컴포넌트
1048
+
1049
+ #### 컴포넌트 정의
1050
+
1051
+ ```gleam
1052
+ // 이름 있는 컴포넌트 (DevTools에 표시)
1053
+ let my_component = react.define_component("MyComponent", fn(props) {
1054
+ html.div_([react.text("Hello")])
1055
+ })
1056
+
1057
+ // React.memo (props 동일 시 리렌더 방지)
1058
+ let memoized = react.memo(my_component)
1059
+
1060
+ // 커스텀 비교 함수
1061
+ let memoized = react.memo_(my_component, fn(prev, next) {
1062
+ // True면 리렌더 건너뜀
1063
+ prev == next
1064
+ })
1065
+ ```
1066
+
1067
+ #### StrictMode
1068
+
1069
+ ```gleam
1070
+ react.strict_mode([
1071
+ // 개발 모드 이중 렌더링 감지
1072
+ my_widget(props),
1073
+ ])
1074
+ ```
1075
+
1076
+ #### Suspense
1077
+
1078
+ ```gleam
1079
+ react.suspense(
1080
+ html.div_([react.text("로딩 중...")]), // fallback
1081
+ [lazy_component], // children
1082
+ )
1083
+ ```
1084
+
1085
+ #### Portal
1086
+
1087
+ ```gleam
1088
+ // 위젯 DOM 외부에 렌더링 (모달, 팝업)
1089
+ react.portal(modal_element, document_body)
1090
+ ```
1091
+
1092
+ #### forwardRef
1093
+
1094
+ ```gleam
1095
+ let fancy_input = react.forward_ref(fn(props, ref) {
1096
+ html.input([attribute.ref(ref), attribute.class("fancy")])
1097
+ })
1098
+ ```
1099
+
1100
+ #### startTransition / flushSync
1101
+
1102
+ ```gleam
1103
+ // 훅 없이 비긴급 업데이트 표시
1104
+ react.start_transition(fn() {
1105
+ set_data(new_data)
1106
+ })
1107
+
1108
+ // 동기 DOM 업데이트 강제 (상태 변경 후 DOM 측정 시 필요) (Round 3)
1109
+ react.flush_sync(fn() {
1110
+ set_count(count + 1)
1111
+ })
1112
+ // 이 시점에 DOM이 이미 업데이트되어 있음
1113
+ ```
1114
+
1115
+ #### Profiler
1116
+
1117
+ ```gleam
1118
+ react.profiler("MyWidget", fn(id, phase, actual, base, start, commit) {
1119
+ // 렌더링 성능 측정
1120
+ Nil
1121
+ }, [my_widget(props)])
1122
+ ```
1123
+
1124
+ #### Context API
1125
+
1126
+ ```gleam
1127
+ // Context 생성
1128
+ let theme_ctx = react.create_context("light")
1129
+
1130
+ // Provider로 값 공급
1131
+ react.provider(theme_ctx, "dark", [
1132
+ child_component,
1133
+ ])
1134
+
1135
+ // 소비 (hook)
1136
+ let theme = hook.use_context(theme_ctx)
1137
+ ```
1138
+
1139
+ ---
1140
+
1141
+ ## 4. Mendix 바인딩
1142
+
1143
+ ### 4.1 Props 접근
1144
+
1145
+ `glendix/mendix` 모듈로 Mendix가 위젯에 전달하는 props에 접근합니다.
1146
+
1147
+ ```gleam
1148
+ import glendix/mendix
1149
+
1150
+ // Option 반환 (undefined면 None)
1151
+ case mendix.get_prop(props, "myAttribute") {
1152
+ Some(attr) -> use_attribute(attr)
1153
+ None -> react.none()
1154
+ }
1155
+
1156
+ // 항상 존재하는 prop (undefined일 수 없는 경우)
1157
+ let value = mendix.get_prop_required(props, "alwaysPresent")
1158
+
1159
+ // String prop (없으면 빈 문자열 반환)
1160
+ let text = mendix.get_string_prop(props, "caption")
1161
+
1162
+ // prop 존재 여부 확인
1163
+ let has_action = mendix.has_prop(props, "onClick")
1164
+ ```
1165
+
1166
+ #### ValueStatus 확인
1167
+
1168
+ Mendix의 모든 동적 값은 상태(status)를 가집니다:
1169
+
1170
+ ```gleam
1171
+ import glendix/mendix.{Available, Loading, Unavailable}
1172
+
1173
+ case mendix.get_status(some_value) {
1174
+ Available -> // 값 사용 가능
1175
+ Loading -> // 로딩 중
1176
+ Unavailable -> // 사용 불가
1177
+ }
1178
+ ```
1179
+
1180
+ ### 4.2 EditableValue — 편집 가능한 값
1181
+
1182
+ `glendix/mendix/editable_value`는 텍스트, 숫자, 날짜 등 편집 가능한 Mendix 속성을 다룹니다.
1183
+
1184
+ ```gleam
1185
+ import gleam/option.{None, Some}
1186
+ import glendix/mendix
1187
+ import glendix/mendix/editable_value as ev
1188
+
1189
+ pub fn text_input(props: JsProps) -> ReactElement {
1190
+ case mendix.get_prop(props, "textAttribute") {
1191
+ Some(attr) -> render_input(attr)
1192
+ None -> react.none()
1193
+ }
1194
+ }
1195
+
1196
+ fn render_input(attr) -> ReactElement {
1197
+ // 값 읽기
1198
+ let current_value = ev.value(attr) // Option(a)
1199
+ let display = ev.display_value(attr) // String (포맷된 표시값)
1200
+ let is_editable = ev.is_editable(attr) // Bool (Available && !read_only)
1201
+
1202
+ // 유효성 검사 메시지 확인
1203
+ let validation_msg = ev.validation(attr) // Option(String)
1204
+
1205
+ html.div_([
1206
+ html.input([
1207
+ attribute.value(display),
1208
+ attribute.readonly(!is_editable),
1209
+ event.on_change(fn(e) {
1210
+ // 텍스트로 값 설정 (Mendix가 파싱)
1211
+ ev.set_text_value(attr, event.target_value(e))
1212
+ }),
1213
+ ]),
1214
+ // 유효성 검사 에러 표시
1215
+ react.when_some(validation_msg, fn(msg) {
1216
+ html.span([attribute.class("text-danger")], [
1217
+ react.text(msg),
1218
+ ])
1219
+ }),
1220
+ ])
1221
+ }
1222
+ ```
1223
+
1224
+ #### 값 설정 방법
1225
+
1226
+ ```gleam
1227
+ // Option으로 직접 설정 (타입이 맞아야 함)
1228
+ ev.set_value(attr, Some(new_value)) // 값 설정
1229
+ ev.set_value(attr, None) // 값 비우기
1230
+
1231
+ // 텍스트로 설정 (Mendix가 자동 파싱 — 숫자, 날짜 등에 유용)
1232
+ ev.set_text_value(attr, "2024-01-15")
1233
+
1234
+ // 커스텀 유효성 검사 함수 설정
1235
+ ev.set_validator(attr, Some(fn(value) {
1236
+ case value {
1237
+ Some(v) if v == "" -> Some("값을 입력하세요")
1238
+ _ -> None // 유효함
1239
+ }
1240
+ }))
1241
+ ```
1242
+
1243
+ #### 가능한 값 목록 (Enum, Boolean 등)
1244
+
1245
+ ```gleam
1246
+ case ev.universe(attr) {
1247
+ Some(options) ->
1248
+ // options: List(a) — 선택 가능한 모든 값
1249
+ html.select_(
1250
+ list.map(options, fn(opt) {
1251
+ html.option_([react.text(string.inspect(opt))])
1252
+ }),
1253
+ )
1254
+ None -> react.none()
1255
+ }
1256
+ ```
1257
+
1258
+ ### 4.3 ActionValue — 액션 실행
1259
+
1260
+ `glendix/mendix/action`으로 Mendix 마이크로플로우/나노플로우를 실행합니다.
1261
+
1262
+ ```gleam
1263
+ import glendix/mendix
1264
+ import glendix/mendix/action
1265
+
1266
+ pub fn action_button(props: JsProps) -> ReactElement {
1267
+ let on_click = mendix.get_prop(props, "onClick") // Option(ActionValue)
1268
+
1269
+ html.button(
1270
+ [
1271
+ attribute.class("btn"),
1272
+ attribute.disabled(case on_click {
1273
+ Some(a) -> !action.can_execute(a)
1274
+ None -> True
1275
+ }),
1276
+ event.on_click(fn(_) {
1277
+ // Option(ActionValue) 안전하게 실행
1278
+ action.execute_action(on_click)
1279
+ }),
1280
+ ],
1281
+ [react.text("실행")],
1282
+ )
1283
+ }
1284
+ ```
1285
+
1286
+ #### 액션 실행 방법
1287
+
1288
+ ```gleam
1289
+ // 직접 실행 (can_execute 확인 없이)
1290
+ action.execute(my_action)
1291
+
1292
+ // can_execute가 True일 때만 실행
1293
+ action.execute_if_can(my_action)
1294
+
1295
+ // Option(ActionValue)에서 안전하게 실행
1296
+ action.execute_action(maybe_action) // None이면 아무것도 안 함
1297
+
1298
+ // 실행 상태 확인
1299
+ let can = action.can_execute(my_action) // Bool
1300
+ let running = action.is_executing(my_action) // Bool
1301
+ ```
1302
+
1303
+ ### 4.4 DynamicValue — 읽기 전용 표현식
1304
+
1305
+ `glendix/mendix/dynamic_value`는 Mendix 표현식(Expression) 속성을 다룹니다.
1306
+
1307
+ ```gleam
1308
+ import glendix/mendix/dynamic_value as dv
1309
+
1310
+ pub fn display_expression(props: JsProps) -> ReactElement {
1311
+ case mendix.get_prop(props, "expression") {
1312
+ Some(expr) ->
1313
+ case dv.value(expr) {
1314
+ Some(text) -> html.span_([react.text(text)])
1315
+ None -> react.none()
1316
+ }
1317
+ None -> react.none()
1318
+ }
1319
+ }
1320
+
1321
+ // 상태 확인
1322
+ let status = dv.status(expr)
1323
+ let ready = dv.is_available(expr)
1324
+ ```
1325
+
1326
+ ### 4.5 ListValue — 리스트 데이터
1327
+
1328
+ `glendix/mendix/list_value`는 Mendix 데이터 소스 리스트를 다룹니다.
1329
+
1330
+ ```gleam
1331
+ import glendix/mendix
1332
+ import glendix/mendix/list_value as lv
1333
+
1334
+ pub fn data_list(props: JsProps) -> ReactElement {
1335
+ case mendix.get_prop(props, "dataSource") {
1336
+ Some(list_val) -> render_list(list_val, props)
1337
+ None -> react.none()
1338
+ }
1339
+ }
1340
+
1341
+ fn render_list(list_val, props) -> ReactElement {
1342
+ case lv.items(list_val) {
1343
+ Some(items) ->
1344
+ html.ul_(
1345
+ list.map(items, fn(item) {
1346
+ let id = mendix.object_id(item)
1347
+ html.li([attribute.key(id)], [
1348
+ react.text("Item: " <> id),
1349
+ ])
1350
+ }),
1351
+ )
1352
+ None ->
1353
+ html.div_([react.text("로딩 중...")])
1354
+ }
1355
+ }
1356
+ ```
1357
+
1358
+ #### 페이지네이션
1359
+
1360
+ ```gleam
1361
+ // 현재 페이지 정보
1362
+ let offset = lv.offset(list_val) // 현재 오프셋
1363
+ let limit = lv.limit(list_val) // 페이지 크기
1364
+ let has_more = lv.has_more_items(list_val) // Option(Bool)
1365
+
1366
+ // 페이지 이동
1367
+ lv.set_offset(list_val, offset + limit) // 다음 페이지
1368
+ lv.set_limit(list_val, 20) // 페이지 크기 변경
1369
+
1370
+ // 전체 개수 요청 (성능 고려)
1371
+ lv.request_total_count(list_val, True)
1372
+ let total = lv.total_count(list_val) // Option(Int)
1373
+ ```
1374
+
1375
+ #### 정렬
1376
+
1377
+ ```gleam
1378
+ import glendix/mendix/list_value as lv
1379
+
1380
+ // 정렬 적용
1381
+ lv.set_sort_order(list_val, [
1382
+ lv.sort("Name", lv.Asc),
1383
+ lv.sort("CreatedDate", lv.Desc),
1384
+ ])
1385
+
1386
+ // 현재 정렬 확인
1387
+ let current_sort = lv.sort_order(list_val)
1388
+ ```
1389
+
1390
+ #### 데이터 갱신
1391
+
1392
+ ```gleam
1393
+ lv.reload(list_val) // 데이터 다시 로드
1394
+ ```
1395
+
1396
+ ### 4.6 ListAttribute — 리스트 아이템 접근
1397
+
1398
+ `glendix/mendix/list_attribute`는 리스트의 각 아이템에서 속성, 액션, 표현식, 위젯을 추출합니다.
1399
+
1400
+ ```gleam
1401
+ import glendix/mendix/list_attribute as la
1402
+
1403
+ pub fn render_table(props: JsProps) -> ReactElement {
1404
+ let list_val = mendix.get_prop_required(props, "dataSource")
1405
+ let name_attr = mendix.get_prop_required(props, "nameAttr")
1406
+ let edit_action = mendix.get_prop(props, "onEdit")
1407
+
1408
+ case lv.items(list_val) {
1409
+ Some(items) ->
1410
+ html.table_([
1411
+ html.tbody_(
1412
+ list.map(items, fn(item) {
1413
+ let id = mendix.object_id(item)
1414
+
1415
+ // 아이템에서 속성값 추출
1416
+ let name_ev = la.get_attribute(name_attr, item)
1417
+ let display = ev.display_value(name_ev)
1418
+
1419
+ // 아이템에서 액션 추출
1420
+ let action_opt = case edit_action {
1421
+ Some(act) -> la.get_action(act, item)
1422
+ None -> None
1423
+ }
1424
+
1425
+ html.tr([attribute.key(id)], [
1426
+ html.td_([react.text(display)]),
1427
+ html.td_([
1428
+ html.button(
1429
+ [event.on_click(fn(_) {
1430
+ action.execute_action(action_opt)
1431
+ })],
1432
+ [react.text("편집")],
1433
+ ),
1434
+ ]),
1435
+ ])
1436
+ }),
1437
+ ),
1438
+ ])
1439
+ None -> html.div_([react.text("로딩 중...")])
1440
+ }
1441
+ }
1442
+ ```
1443
+
1444
+ #### ListAttributeValue 메타데이터
1445
+
1446
+ ```gleam
1447
+ // 속성 정보 확인
1448
+ let id = la.attr_id(name_attr) // String - 속성 ID
1449
+ let sortable = la.attr_sortable(name_attr) // Bool
1450
+ let filterable = la.attr_filterable(name_attr) // Bool
1451
+ let type_name = la.attr_type(name_attr) // "String", "Integer" 등
1452
+ let formatter = la.attr_formatter(name_attr) // ValueFormatter
1453
+ ```
1454
+
1455
+ #### 위젯 렌더링
1456
+
1457
+ ```gleam
1458
+ // 리스트 아이템별 위젯 (Mendix Studio에서 구성)
1459
+ let content_widget = mendix.get_prop_required(props, "content")
1460
+
1461
+ list.map(items, fn(item) {
1462
+ let widget_element = la.get_widget(content_widget, item)
1463
+ html.div([attribute.key(mendix.object_id(item))], [
1464
+ widget_element, // ReactElement로 직접 사용
1465
+ ])
1466
+ })
1467
+ ```
1468
+
1469
+ ### 4.7 Selection — 선택
1470
+
1471
+ `glendix/mendix/selection`으로 단일/다중 선택을 관리합니다.
1472
+
1473
+ #### 단일 선택
1474
+
1475
+ ```gleam
1476
+ import glendix/mendix/selection
1477
+
1478
+ // 현재 선택된 항목
1479
+ let selected = selection.selection(single_sel) // Option(ObjectItem)
1480
+
1481
+ // 선택 설정/해제
1482
+ selection.set_selection(single_sel, Some(item)) // 선택
1483
+ selection.set_selection(single_sel, None) // 선택 해제
1484
+ ```
1485
+
1486
+ #### 다중 선택
1487
+
1488
+ ```gleam
1489
+ // 선택된 항목들
1490
+ let selected_items = selection.selections(multi_sel) // List(ObjectItem)
1491
+
1492
+ // 선택 설정
1493
+ selection.set_selections(multi_sel, [item1, item2])
1494
+ ```
1495
+
1496
+ ### 4.8 Reference — 연관 관계
1497
+
1498
+ `glendix/mendix/reference`로 단일 연관 관계, `glendix/mendix/reference_set`으로 다중 연관 관계를 다룹니다.
1499
+
1500
+ ```gleam
1501
+ import glendix/mendix/reference as ref
1502
+ import glendix/mendix/reference_set as ref_set
1503
+
1504
+ // 단일 참조 (1:1, N:1)
1505
+ let referenced = ref.value(my_ref) // Option(a)
1506
+ let is_readonly = ref.read_only(my_ref) // Bool
1507
+ let error = ref.validation(my_ref) // Option(String)
1508
+
1509
+ ref.set_value(my_ref, Some(new_item)) // 참조 설정
1510
+ ref.set_value(my_ref, None) // 참조 해제
1511
+
1512
+ // 다중 참조 (M:N)
1513
+ let items = ref_set.value(my_ref_set) // Option(List(a))
1514
+ ref_set.set_value(my_ref_set, Some([item1, item2]))
1515
+ ```
1516
+
1517
+ ### 4.9 Filter — 필터 조건 빌더
1518
+
1519
+ `glendix/mendix/filter`로 ListValue에 적용할 필터 조건을 프로그래밍 방식으로 구성합니다.
1520
+
1521
+ ```gleam
1522
+ import glendix/mendix/filter
1523
+ import glendix/mendix/list_value as lv
1524
+
1525
+ // 단순 비교
1526
+ let name_filter =
1527
+ filter.contains(
1528
+ filter.attribute("Name"),
1529
+ filter.literal("검색어"),
1530
+ )
1531
+
1532
+ // 복합 조건 (AND)
1533
+ let complex_filter =
1534
+ filter.and_([
1535
+ filter.equals(
1536
+ filter.attribute("Status"),
1537
+ filter.literal("Active"),
1538
+ ),
1539
+ filter.greater_than(
1540
+ filter.attribute("Amount"),
1541
+ filter.literal(100),
1542
+ ),
1543
+ ])
1544
+
1545
+ // 필터 적용
1546
+ lv.set_filter(list_val, Some(complex_filter))
1547
+
1548
+ // 필터 해제
1549
+ lv.set_filter(list_val, None)
1550
+ ```
1551
+
1552
+ #### 사용 가능한 비교 연산자
1553
+
1554
+ | 함수 | 설명 |
1555
+ |---|---|
1556
+ | `equals(a, b)` | 같음 |
1557
+ | `not_equal(a, b)` | 다름 |
1558
+ | `greater_than(a, b)` | 초과 |
1559
+ | `greater_than_or_equal(a, b)` | 이상 |
1560
+ | `less_than(a, b)` | 미만 |
1561
+ | `less_than_or_equal(a, b)` | 이하 |
1562
+ | `contains(a, b)` | 포함 (문자열) |
1563
+ | `starts_with(a, b)` | 시작 (문자열) |
1564
+ | `ends_with(a, b)` | 끝 (문자열) |
1565
+
1566
+ #### 날짜 비교
1567
+
1568
+ ```gleam
1569
+ filter.day_equals(filter.attribute("Birthday"), filter.literal(date))
1570
+ filter.day_greater_than(filter.attribute("CreatedDate"), filter.literal(start_date))
1571
+ ```
1572
+
1573
+ #### 논리 조합
1574
+
1575
+ ```gleam
1576
+ filter.and_([condition1, condition2]) // AND
1577
+ filter.or_([condition1, condition2]) // OR
1578
+ filter.not_(condition) // NOT
1579
+ ```
1580
+
1581
+ #### 표현식 타입
1582
+
1583
+ ```gleam
1584
+ filter.attribute("AttrName") // 속성 참조
1585
+ filter.association("AssocName") // 연관 관계 참조
1586
+ filter.literal(value) // 상수 값
1587
+ filter.empty() // 빈 값 (null 비교용)
1588
+ ```
1589
+
1590
+ ### 4.10 날짜와 숫자
1591
+
1592
+ #### JsDate — 날짜 처리
1593
+
1594
+ `glendix/mendix/date`는 JavaScript Date를 Gleam에서 안전하게 다룹니다.
1595
+
1596
+ > 핵심: Gleam에서 월(month)은 **1-based** (1~12), JavaScript에서는 0-based (0~11). glendix가 자동 변환합니다.
1597
+
1598
+ ```gleam
1599
+ import glendix/mendix/date
1600
+
1601
+ // 생성
1602
+ let now = date.now()
1603
+ let parsed = date.from_iso("2024-03-15T10:30:00Z")
1604
+ let custom = date.create(2024, 3, 15, 10, 30, 0, 0) // 월: 1-12!
1605
+ let from_ts = date.from_timestamp(1710500000000)
1606
+
1607
+ // 읽기
1608
+ let year = date.year(now) // 예: 2024
1609
+ let month = date.month(now) // 1~12 (자동 변환!)
1610
+ let day = date.day(now) // 1~31
1611
+ let hours = date.hours(now) // 0~23
1612
+ let dow = date.day_of_week(now) // 0=일요일
1613
+
1614
+ // 변환
1615
+ let iso = date.to_iso(now) // "2024-03-15T10:30:00.000Z"
1616
+ let ts = date.to_timestamp(now) // Unix 밀리초
1617
+ let str = date.to_string(now) // 사람이 읽을 수 있는 형식
1618
+ let input_val = date.to_input_value(now) // "2024-03-15" (input[type="date"]용)
1619
+
1620
+ // input[type="date"]에서 파싱
1621
+ let maybe_date = date.from_input_value("2024-03-15") // Option(JsDate)
1622
+ ```
1623
+
1624
+ #### Big — 고정밀 십진수
1625
+
1626
+ `glendix/mendix/big`는 Big.js를 래핑하여 Mendix의 Decimal 타입을 정밀하게 처리합니다.
1627
+
1628
+ ```gleam
1629
+ import glendix/mendix/big
1630
+ import gleam/order
1631
+
1632
+ // 생성
1633
+ let a = big.from_string("123.456")
1634
+ let b = big.from_int(100)
1635
+ let c = big.from_float(99.99)
1636
+
1637
+ // 연산
1638
+ let sum = big.add(a, b) // 223.456
1639
+ let diff = big.subtract(a, b) // 23.456
1640
+ let prod = big.multiply(a, b) // 12345.6
1641
+ let quot = big.divide(a, b) // 1.23456
1642
+ let abs = big.absolute(diff) // 양수화
1643
+ let neg = big.negate(a) // -123.456
1644
+
1645
+ // 비교
1646
+ let cmp = big.compare(a, b) // order.Gt
1647
+ let eq = big.equal(a, b) // False
1648
+
1649
+ // 변환
1650
+ let str = big.to_string(sum) // "223.456"
1651
+ let f = big.to_float(sum) // 223.456
1652
+ let i = big.to_int(sum) // 223 (소수점 버림)
1653
+ let fixed = big.to_fixed(sum, 2) // "223.46"
1654
+ ```
1655
+
1656
+ ### 4.11 파일, 아이콘, 포맷터
1657
+
1658
+ #### FileValue / WebImage
1659
+
1660
+ ```gleam
1661
+ import glendix/mendix/file
1662
+
1663
+ // FileValue
1664
+ let uri = file.uri(file_val) // String - 파일 URI
1665
+ let name = file.name(file_val) // Option(String) - 파일명
1666
+
1667
+ // WebImage (FileValue + alt 텍스트)
1668
+ let src = file.image_uri(img) // String
1669
+ let alt = file.alt_text(img) // Option(String)
1670
+
1671
+ html.img([
1672
+ attribute.src(src),
1673
+ attribute.alt(option.unwrap(alt, "")),
1674
+ ])
1675
+ ```
1676
+
1677
+ #### WebIcon
1678
+
1679
+ ```gleam
1680
+ import glendix/mendix/icon
1681
+
1682
+ case icon.icon_type(my_icon) {
1683
+ icon.Glyph ->
1684
+ html.span([attribute.class(icon.icon_class(my_icon))], [])
1685
+ icon.Image ->
1686
+ html.img([attribute.src(icon.icon_url(my_icon))])
1687
+ icon.IconFont ->
1688
+ html.span([attribute.class(icon.icon_class(my_icon))], [])
1689
+ }
1690
+ ```
1691
+
1692
+ #### ValueFormatter
1693
+
1694
+ ```gleam
1695
+ import glendix/mendix/formatter
1696
+
1697
+ // 값을 문자열로 포맷
1698
+ let display = formatter.format(fmt, Some(value)) // String
1699
+ let empty = formatter.format(fmt, None) // ""
1700
+
1701
+ // 텍스트를 값으로 파싱
1702
+ case formatter.parse(fmt, "123.45") {
1703
+ Ok(Some(value)) -> // 파싱 성공
1704
+ Ok(None) -> // 빈 값
1705
+ Error(Nil) -> // 파싱 실패
1706
+ }
1707
+ ```
1708
+
1709
+ ### 4.12 Editor Configuration — 조건부 속성 제어
1710
+
1711
+ Studio Pro의 editorConfig에서 속성을 조건부로 숨기거나, 그룹을 탭으로 변환하는 등의 작업을 순수 Gleam으로 작성할 수 있습니다. `@mendix/pluggable-widgets-tools`의 헬퍼 함수를 래핑합니다.
1712
+
1713
+ #### Properties 타입
1714
+
1715
+ `Properties`는 Studio Pro가 `getProperties`에 전달하는 `PropertyGroup[]` 배열의 opaque 래퍼입니다. 모든 함수가 `Properties`를 반환하므로 파이프라인 체이닝이 가능합니다.
1716
+
1717
+ #### 속성 숨기기
1718
+
1719
+ ```gleam
1720
+ import glendix/editor_config.{type Properties}
1721
+
1722
+ // 단일 속성 숨기기
1723
+ let props = editor_config.hide_property(default_properties, "barWidth")
1724
+
1725
+ // 여러 속성 한 번에 숨기기
1726
+ let props = editor_config.hide_properties(default_properties, ["barWidth", "barColor"])
1727
+
1728
+ // 중첩 속성 숨기기 (배열 타입 속성의 특정 인덱스 내부)
1729
+ let props = editor_config.hide_nested_property(default_properties, "columns", 0, "width")
1730
+
1731
+ // 여러 중첩 속성 한 번에 숨기기
1732
+ let props = editor_config.hide_nested_properties(default_properties, "columns", 0, ["width", "alignment"])
1733
+ ```
1734
+
1735
+ #### 변환 / 속성 순서 변경
1736
+
1737
+ ```gleam
1738
+ // 속성 그룹을 탭으로 변환 (웹 플랫폼용)
1739
+ let props = editor_config.transform_groups_into_tabs(default_properties)
1740
+
1741
+ // 속성 순서 변경 (from_index → to_index)
1742
+ let props = editor_config.move_property(default_properties, 0, 2)
1743
+ ```
1744
+
1745
+ #### 실전 예시 — 차트 유형별 조건부 속성
1746
+
1747
+ 사용자의 `src/editor_config.gleam`에서 `getProperties` 로직을 작성합니다. 이 파일이 존재하면 `run_with_bridge` 실행 시 editorConfig 브릿지 JS가 자동 생성됩니다.
1748
+
1749
+ ```gleam
1750
+ import glendix/editor_config.{type Properties}
1751
+ import glendix/mendix
1752
+ import glendix/react.{type JsProps}
1753
+
1754
+ pub fn get_properties(
1755
+ values: JsProps,
1756
+ default_properties: Properties,
1757
+ platform: String,
1758
+ ) -> Properties {
1759
+ let chart_type = mendix.get_string_prop(values, "chartType")
1760
+
1761
+ let props = case chart_type {
1762
+ "line" ->
1763
+ default_properties
1764
+ |> editor_config.hide_properties(["barWidth", "barColor"])
1765
+ "bar" ->
1766
+ default_properties
1767
+ |> editor_config.hide_properties(["lineStyle", "lineCurve"])
1768
+ _ -> default_properties
1769
+ }
1770
+
1771
+ case platform {
1772
+ "web" -> editor_config.transform_groups_into_tabs(props)
1773
+ _ -> props
1774
+ }
1775
+ }
1776
+ ```
1777
+
1778
+ #### 함수 요약
1779
+
1780
+ | 함수 | 설명 |
1781
+ |------|------|
1782
+ | `hide_property(properties, key)` | 단일 속성 숨기기 |
1783
+ | `hide_properties(properties, keys)` | 여러 속성 한 번에 숨기기 |
1784
+ | `hide_nested_property(properties, key, index, nested_key)` | 중첩 속성 숨기기 |
1785
+ | `hide_nested_properties(properties, key, index, nested_keys)` | 여러 중첩 속성 숨기기 |
1786
+ | `transform_groups_into_tabs(properties)` | 그룹 → 탭 변환 |
1787
+ | `move_property(properties, from_index, to_index)` | 속성 순서 변경 |
1788
+
1789
+ ---
1790
+
1791
+ ## 5. 실전 패턴
1792
+
1793
+ ### 5.1 폼 입력 위젯
1794
+
1795
+ ```gleam
1796
+ import gleam/option.{None, Some}
1797
+ import glendix/mendix
1798
+ import glendix/mendix/action
1799
+ import glendix/mendix/editable_value as ev
1800
+ import glendix/react.{type JsProps, type ReactElement}
1801
+ import glendix/react/attribute
1802
+ import glendix/react/event
1803
+ import glendix/react/hook
1804
+ import glendix/react/html
1805
+
1806
+ pub fn text_input_widget(props: JsProps) -> ReactElement {
1807
+ let attr = mendix.get_prop(props, "textAttribute")
1808
+ let on_enter = mendix.get_prop(props, "onEnterAction")
1809
+ let placeholder = mendix.get_string_prop(props, "placeholder")
1810
+
1811
+ case attr {
1812
+ Some(text_attr) -> {
1813
+ let display = ev.display_value(text_attr)
1814
+ let editable = ev.is_editable(text_attr)
1815
+ let validation = ev.validation(text_attr)
1816
+
1817
+ html.div([attribute.class("form-group")], [
1818
+ html.input([
1819
+ attribute.class("form-control"),
1820
+ attribute.value(display),
1821
+ attribute.placeholder(placeholder),
1822
+ attribute.readonly(!editable),
1823
+ event.on_change(fn(e) {
1824
+ ev.set_text_value(text_attr, event.target_value(e))
1825
+ }),
1826
+ event.on_key_down(fn(e) {
1827
+ case event.key(e) {
1828
+ "Enter" -> action.execute_action(on_enter)
1829
+ _ -> Nil
1830
+ }
1831
+ }),
1832
+ ]),
1833
+ // 유효성 검사 메시지
1834
+ react.when_some(validation, fn(msg) {
1835
+ html.div([attribute.class("alert alert-danger")], [
1836
+ react.text(msg),
1837
+ ])
1838
+ }),
1839
+ ])
1840
+ }
1841
+ None -> react.none()
1842
+ }
1843
+ }
1844
+ ```
1845
+
1846
+ ### 5.2 데이터 테이블 위젯
1847
+
1848
+ ```gleam
1849
+ import gleam/int
1850
+ import gleam/list
1851
+ import gleam/option.{None, Some}
1852
+ import glendix/mendix
1853
+ import glendix/mendix/editable_value as ev
1854
+ import glendix/mendix/list_attribute as la
1855
+ import glendix/mendix/list_value as lv
1856
+ import glendix/react.{type JsProps, type ReactElement}
1857
+ import glendix/react/attribute
1858
+ import glendix/react/event
1859
+ import glendix/react/html
1860
+
1861
+ pub fn data_table(props: JsProps) -> ReactElement {
1862
+ let ds = mendix.get_prop_required(props, "dataSource")
1863
+ let col_name = mendix.get_prop_required(props, "nameColumn")
1864
+ let col_status = mendix.get_prop_required(props, "statusColumn")
1865
+
1866
+ html.div([attribute.class("table-responsive")], [
1867
+ html.table([attribute.class("table table-striped")], [
1868
+ // 헤더
1869
+ html.thead_([
1870
+ html.tr_([
1871
+ html.th_([react.text("이름")]),
1872
+ html.th_([react.text("상태")]),
1873
+ ]),
1874
+ ]),
1875
+ // 바디
1876
+ html.tbody_(
1877
+ case lv.items(ds) {
1878
+ Some(items) ->
1879
+ list.map(items, fn(item) {
1880
+ let id = mendix.object_id(item)
1881
+ let name = ev.display_value(la.get_attribute(col_name, item))
1882
+ let status = ev.display_value(la.get_attribute(col_status, item))
1883
+
1884
+ html.tr([attribute.key(id)], [
1885
+ html.td_([react.text(name)]),
1886
+ html.td_([react.text(status)]),
1887
+ ])
1888
+ })
1889
+ None -> [
1890
+ html.tr_([
1891
+ html.td(
1892
+ [attribute.col_span(2)],
1893
+ [react.text("로딩 중...")],
1894
+ ),
1895
+ ]),
1896
+ ]
1897
+ },
1898
+ ),
1899
+ ]),
1900
+ // 페이지네이션
1901
+ render_pagination(ds),
1902
+ ])
1903
+ }
1904
+
1905
+ fn render_pagination(ds) -> ReactElement {
1906
+ let offset = lv.offset(ds)
1907
+ let limit = lv.limit(ds)
1908
+ let has_more = lv.has_more_items(ds)
1909
+
1910
+ html.div([attribute.class("pagination")], [
1911
+ html.button(
1912
+ [
1913
+ attribute.disabled(offset == 0),
1914
+ event.on_click(fn(_) {
1915
+ lv.set_offset(ds, int.max(0, offset - limit))
1916
+ }),
1917
+ ],
1918
+ [react.text("이전")],
1919
+ ),
1920
+ html.button(
1921
+ [
1922
+ attribute.disabled(has_more == Some(False)),
1923
+ event.on_click(fn(_) {
1924
+ lv.set_offset(ds, offset + limit)
1925
+ }),
1926
+ ],
1927
+ [react.text("다음")],
1928
+ ),
1929
+ ])
1930
+ }
1931
+ ```
1932
+
1933
+ ### 5.3 검색 가능한 리스트
1934
+
1935
+ ```gleam
1936
+ import gleam/option.{None, Some}
1937
+ import glendix/mendix
1938
+ import glendix/mendix/filter
1939
+ import glendix/mendix/list_value as lv
1940
+ import glendix/react.{type JsProps, type ReactElement}
1941
+ import glendix/react/attribute
1942
+ import glendix/react/event
1943
+ import glendix/react/hook
1944
+ import glendix/react/html
1945
+
1946
+ pub fn searchable_list(props: JsProps) -> ReactElement {
1947
+ let ds = mendix.get_prop_required(props, "dataSource")
1948
+ let search_attr = mendix.get_string_prop(props, "searchAttribute")
1949
+ let #(query, set_query) = hook.use_state("")
1950
+
1951
+ // 검색어 변경 시 필터 적용
1952
+ hook.use_effect(fn() {
1953
+ case query {
1954
+ "" -> lv.set_filter(ds, None)
1955
+ q ->
1956
+ lv.set_filter(ds, Some(
1957
+ filter.contains(
1958
+ filter.attribute(search_attr),
1959
+ filter.literal(q),
1960
+ ),
1961
+ ))
1962
+ }
1963
+ Nil
1964
+ }, [query])
1965
+
1966
+ html.div_([
1967
+ // 검색 입력
1968
+ html.input([
1969
+ attribute.class("form-control"),
1970
+ attribute.type_("search"),
1971
+ attribute.placeholder("검색..."),
1972
+ attribute.value(query),
1973
+ event.on_change(fn(e) { set_query(event.target_value(e)) }),
1974
+ ]),
1975
+ // 결과 리스트 렌더링
1976
+ render_results(ds),
1977
+ ])
1978
+ }
1979
+ ```
1980
+
1981
+ ### 5.4 컴포넌트 합성
1982
+
1983
+ Gleam 함수를 컴포넌트처럼 활용하여 UI를 분리합니다:
1984
+
1985
+ ```gleam
1986
+ import glendix/react.{type ReactElement}
1987
+ import glendix/react/attribute
1988
+ import glendix/react/html
1989
+
1990
+ // 재사용 가능한 카드 컴포넌트
1991
+ fn card(title: String, children: List(ReactElement)) -> ReactElement {
1992
+ html.div([attribute.class("card")], [
1993
+ html.div([attribute.class("card-header")], [
1994
+ html.h3_([react.text(title)]),
1995
+ ]),
1996
+ html.div([attribute.class("card-body")], children),
1997
+ ])
1998
+ }
1999
+
2000
+ // 재사용 가능한 빈 상태 컴포넌트
2001
+ fn empty_state(message: String) -> ReactElement {
2002
+ html.div([attribute.class("empty-state")], [
2003
+ html.p_([react.text(message)]),
2004
+ ])
2005
+ }
2006
+
2007
+ // 조합하여 사용
2008
+ pub fn dashboard(props) -> ReactElement {
2009
+ html.div([attribute.class("dashboard")], [
2010
+ card("사용자 목록", [
2011
+ // 리스트 내용...
2012
+ ]),
2013
+ card("최근 활동", [
2014
+ empty_state("아직 활동이 없습니다."),
2015
+ ]),
2016
+ ])
2017
+ }
2018
+ ```
2019
+
2020
+ ### 5.5 SVG 아이콘 컴포넌트
2021
+
2022
+ ```gleam
2023
+ import glendix/react.{type ReactElement}
2024
+ import glendix/react/attribute
2025
+ import glendix/react/svg
2026
+ import glendix/react/svg_attribute as sa
2027
+
2028
+ fn check_icon(size: String) -> ReactElement {
2029
+ svg.svg(
2030
+ [
2031
+ sa.view_box("0 0 24 24"),
2032
+ attribute.width(size),
2033
+ attribute.height(size),
2034
+ sa.fill("none"),
2035
+ sa.stroke("currentColor"),
2036
+ sa.stroke_width("2"),
2037
+ sa.stroke_linecap("round"),
2038
+ sa.stroke_linejoin("round"),
2039
+ ],
2040
+ [svg.path([sa.d("M20 6L9 17l-5-5")], [])],
2041
+ )
2042
+ }
2043
+ ```
2044
+
2045
+ ### 5.6 Marketplace 위젯 다운로드
2046
+
2047
+ Mendix Marketplace에서 위젯(.mpk)을 인터랙티브하게 검색하고 다운로드할 수 있습니다. 다운로드 완료 후 바인딩 `.gleam` 파일이 자동 생성되어, 별도의 수동 설정 없이 바로 사용할 수 있습니다.
2048
+
2049
+ #### 사전 준비
2050
+
2051
+ `.env` 파일에 Mendix Personal Access Token을 설정합니다:
2052
+
2053
+ ```
2054
+ MENDIX_PAT=your_personal_access_token
2055
+ ```
2056
+
2057
+ > PAT는 [Mendix Developer Settings](https://user-settings.mendix.com/link/developersettings)에서 **Personal Access Tokens** 섹션의 **New Token**을 클릭하여 발급합니다.
2058
+ > 필요한 scope: `mx:marketplace-content:read`
2059
+
2060
+ #### 실행
2061
+
2062
+ ```bash
2063
+ gleam run -m glendix/marketplace
2064
+ ```
2065
+
2066
+ #### 인터랙티브 TUI
2067
+
2068
+ 실행하면 Content API(`GET /content`)로 위젯 목록을 로드하고, 인터랙티브 TUI가 표시됩니다:
2069
+
2070
+ ```
2071
+ ── 페이지 1/5+ ──
2072
+
2073
+ [0] Star Rating (54611) v3.2.2 Mendix
2074
+ [1] Switch (50324) v4.0.0 — Mendix
2075
+ [2] Progress Bar (48019) v3.1.0 — Mendix
2076
+ ...
2077
+
2078
+ 번호: 다운로드 | 검색어: 이름 검색 | n: 다음 | p: 이전 | r: 초기화 | q: 종료
2079
+
2080
+ >
2081
+ ```
2082
+
2083
+ **주요 명령어:**
2084
+
2085
+ | 입력 | 동작 |
2086
+ |------|------|
2087
+ | `0` | 0번 위젯 다운로드 |
2088
+ | `0,1,3` | 여러 위젯 동시 다운로드 (쉼표 구분) |
2089
+ | `star` | 이름/퍼블리셔로 검색 필터링 |
2090
+ | `n` / `p` | 다음/이전 페이지 |
2091
+ | `r` | 검색 초기화 (전체 목록 복귀) |
2092
+ | `q` | 종료 |
2093
+
2094
+ #### 버전 선택
2095
+
2096
+ 위젯을 선택하면 버전 목록이 표시됩니다. Pluggable/Classic 타입이 자동 구분됩니다:
2097
+
2098
+ ```
2099
+ Star Rating — 버전 선택:
2100
+
2101
+ [0] v3.2.2 (2024-01-15) (Mendix ≥9.24.0) [Pluggable] ← 기본
2102
+ [1] v3.1.0 (2023-08-20) (Mendix ≥9.18.0) [Pluggable]
2103
+ [2] v2.5.1 (2022-03-10) (Mendix ≥8.0.0) [Classic]
2104
+
2105
+ 버전 번호 (Enter=최신):
2106
+ ```
2107
+
2108
+ Enter를 누르면 최신 버전이 다운로드됩니다.
2109
+
2110
+ #### 동작 흐름
2111
+
2112
+ 1. **첫 배치 로드** Content API에서 40개 아이템을 직접 로드하여 즉시 표시
2113
+ 2. **백그라운드 로드** 나머지 아이템을 별도 프로세스에서 비동기 로드 (`.marketplace-cache/`에 캐시)
2114
+ 3. **위젯 선택 시** Playwright(headless chromium)로 Marketplace 페이지에서 S3 다운로드 URL 추출
2115
+ 4. **다운로드** S3에서 `.mpk` 파일을 `widgets/` 디렉토리에 저장
2116
+ 5. **바인딩 생성** `cmd.generate_widget_bindings()`가 자동 호출되어 `src/widgets/`에 바인딩 `.gleam` 파일 생성
2117
+
2118
+ > 버전 정보 조회에 Playwright를 사용하므로, 다운로드 브라우저 로그인이 필요합니다. 세션은 `.marketplace-cache/session.json`에 저장되어 이후 재사용됩니다.
2119
+
2120
+ #### 다운로드 사용
2121
+
2122
+ 다운로드된 위젯은 자동으로 바인딩이 생성됩니다. Pluggable 위젯과 Classic 위젯은 각각 다른 패턴으로 사용합니다:
2123
+
2124
+ **Pluggable 위젯** (`glendix/widget` 사용):
2125
+
2126
+ ```gleam
2127
+ // src/widgets/star_rating.gleam (자동 생성)
2128
+ import glendix/mendix
2129
+ import glendix/react.{type JsProps, type ReactElement}
2130
+ import glendix/react/attribute
2131
+ import glendix/widget
2132
+
2133
+ pub fn render(props: JsProps) -> ReactElement {
2134
+ let rate_attribute = mendix.get_prop_required(props, "rateAttribute")
2135
+ let comp = widget.component("StarRating")
2136
+ react.component_el(
2137
+ comp,
2138
+ [attribute.attribute("rateAttribute", rate_attribute)],
2139
+ [],
2140
+ )
2141
+ }
2142
+ ```
2143
+
2144
+ **Classic (Dojo) 위젯** (`glendix/classic` 사용):
2145
+
2146
+ ```gleam
2147
+ // src/widgets/camera_widget.gleam (자동 생성)
2148
+ import gleam/dynamic
2149
+ import glendix/classic
2150
+ import glendix/mendix
2151
+ import glendix/react.{type JsProps, type ReactElement}
2152
+
2153
+ pub fn render(props: JsProps) -> ReactElement {
2154
+ let mf_to_execute = mendix.get_prop_required(props, "mfToExecute")
2155
+ classic.render("CameraWidget.widget.CameraWidget", [
2156
+ #("mfToExecute", dynamic.from(mf_to_execute)),
2157
+ ])
2158
+ }
2159
+ ```
2160
+
2161
+ **위젯에서 import:**
2162
+
2163
+ ```gleam
2164
+ import widgets/star_rating
2165
+ import widgets/camera_widget
2166
+
2167
+ // 컴포넌트 내부에서
2168
+ star_rating.render(props)
2169
+ camera_widget.render(props)
2170
+ ```
2171
+
2172
+ 생성된 `src/widgets/*.gleam` 파일은 자유롭게 수정할 수 있으며, 이미 존재하는 파일은 재생성 시 덮어쓰지 않습니다.
2173
+
2174
+ ---
2175
+
2176
+ ## 6. 트러블슈팅
2177
+
2178
+ ### 빌드 에러
2179
+
2180
+ | 문제 | 원인 | 해결 |
2181
+ |---|---|---|
2182
+ | `gleam build` 실패: glendix를 찾을 수 없음 | `gleam.toml`의 경로가 잘못됨 | `path = "../glendix"` 경로 확인 |
2183
+ | `react is not defined` | peer dependency 미설치 | `gleam run -m glendix/install` |
2184
+ | `Big is not a constructor` | big.js 미설치 | `gleam run -m glendix/install` |
2185
+
2186
+ ### 런타임 에러
2187
+
2188
+ | 문제 | 원인 | 해결 |
2189
+ |---|---|---|
2190
+ | `Cannot read property of undefined` | 존재하지 않는 prop 접근 | `get_prop` (Option) 대신 `get_prop_required` 사용 시 prop 이름 확인 |
2191
+ | `set_value` 호출 시 에러 | read_only 상태에서 값 설정 | `ev.is_editable(attr)` 확인 후 설정 |
2192
+ | Hook 순서 에러 | 조건부로 Hook 호출 | Hook은 항상 동일한 순서로 호출해야 함 (React Rules of Hooks) |
2193
+ | `바인딩이 생성되지 않았습니다` | `binding_ffi.mjs`가 스텁 상태 | `gleam run -m glendix/install` 실행 |
2194
+ | `위젯 바인딩이 생성되지 않았습니다` | `widget_ffi.mjs`가 스텁 상태 | `widgets/` 디렉토리에 `.mpk` 배치 후 `gleam run -m glendix/install` 실행 |
2195
+ | `위젯 바인딩에 등록되지 않은 위젯` | 해당 `.mpk`가 `widgets/`에 없음 | `.mpk` 파일 배치 후 재설치 |
2196
+ | `could not be resolved – treating it as an external dependency` | `bindings.json`에 등록한 패키지가 `node_modules`에 없음 | `npm install <패키지명>` 등으로 설치 후 재빌드 |
2197
+ | `바인딩에 등록되지 않은 모듈` | `bindings.json`에 해당 패키지 미등록 | `bindings.json`에 패키지와 컴포넌트 추가 후 재설치 |
2198
+ | `모듈에 없는 컴포넌트` | `bindings.json`의 `components`에 해당 컴포넌트 미등록 | `components` 배열에 추가 후 재설치 |
2199
+ | `.env 파일에 MENDIX_PAT가 필요합니다` | marketplace 실행 시 PAT 미설정 | `.env`에 `MENDIX_PAT=...` 추가 (scope: `mx:marketplace-content:read`) — [Developer Settings](https://user-settings.mendix.com/link/developersettings)에서 발급 |
2200
+ | `인증 실패MENDIX_PAT를 확인하세요` | PAT가 잘못되었거나 만료됨 | [Developer Settings](https://user-settings.mendix.com/link/developersettings)에서 PAT 발급 |
2201
+ | `위젯을 불러올 수 없습니다` | Content API 접근 실패 | 네트워크 및 PAT 확인 |
2202
+ | `Playwright 오류` | chromium 미설치 또는 세션 만료 | `npx playwright install chromium` 실행, 또는 브라우저 재로그인 |
2203
+ | `저장된 세션이 만료되었습니다` | Mendix 로그인 세션 만료 | 브라우저 로그인 팝업에서 재로그인 |
2204
+
2205
+ ### 일반적인 실수
2206
+
2207
+ **1. Hook을 조건부로 호출하지 마세요:**
2208
+
2209
+ ```gleam
2210
+ // 잘못된 예
2211
+ pub fn widget(props) {
2212
+ case mendix.get_prop(props, "attr") {
2213
+ Some(attr) -> {
2214
+ let #(count, set_count) = hook.use_state(0) // 조건 안에서 Hook!
2215
+ // ...
2216
+ }
2217
+ None -> react.none()
2218
+ }
2219
+ }
2220
+
2221
+ // 올바른 예
2222
+ pub fn widget(props) {
2223
+ let #(count, set_count) = hook.use_state(0) // 항상 최상위에서 호출
2224
+
2225
+ case mendix.get_prop(props, "attr") {
2226
+ Some(attr) -> // count 사용...
2227
+ None -> react.none()
2228
+ }
2229
+ }
2230
+ ```
2231
+
2232
+ **2. 리스트 렌더링에서 key를 빠뜨리지 마세요:**
2233
+
2234
+ ```gleam
2235
+ // key가 있어야 React가 효율적으로 업데이트합니다
2236
+ list.map(items, fn(item) {
2237
+ html.div([attribute.key(mendix.object_id(item))], [
2238
+ // ...
2239
+ ])
2240
+ })
2241
+ ```
2242
+
2243
+ **3. 월(month) 변환을 직접 하지 마세요:**
2244
+
2245
+ ```gleam
2246
+ // glendix/mendix/date가 자동으로 1-based ↔ 0-based 변환합니다
2247
+ let month = date.month(my_date) // 1~12 (Gleam 기준, 변환 불필요)
2248
+ ```
2249
+
2250
+ **4. 외부 React 컴포넌트용 `.mjs` 파일을 직접 작성하지 마세요:**
2251
+
2252
+ ```gleam
2253
+ // 잘못된 방법 — 수동 FFI 작성
2254
+ // recharts_ffi.mjs를 만들고 @external로 연결하는 것
2255
+
2256
+ // 올바른 방법 — bindings.json + glendix/binding 사용
2257
+ import glendix/binding
2258
+ let rc = binding.module("recharts")
2259
+ react.component_el(binding.resolve(rc, "PieChart"), attrs, children)
2260
+ react.void_component_el(binding.resolve(rc, "Tooltip"), attrs)
2261
+ ```
2262
+
2263
+ **5. `.mpk` 위젯용 `.mjs` 파일을 직접 작성하지 마세요:**
2264
+
2265
+ ```gleam
2266
+ // 잘못된 방법 — 수동 FFI 작성
2267
+
2268
+ // 올바른 방법 — widgets/ 디렉토리 + glendix/widget 사용
2269
+ import glendix/widget
2270
+ let switch_comp = widget.component("Switch")
2271
+ react.component_el(switch_comp, attrs, children)
2272
+ ```
2273
+
2274
+ **6. `binding.resolve()`에서 컴포넌트 이름을 snake_case로 바꾸지 마세요:**
2275
+
2276
+ ```gleam
2277
+ // 잘못된 예
2278
+ binding.resolve(m(), "pie_chart")
2279
+
2280
+ // 올바른 예 — JavaScript 원본 이름(PascalCase) 그대로 사용
2281
+ binding.resolve(m(), "PieChart")
2282
+ ```
2283
+
2284
+ **7. `react.none()` 대신 빈 문자열이나 빈 리스트를 사용하지 마세요:**
2285
+
2286
+ ```gleam
2287
+ // 잘못된 예
2288
+ react.text("") // 빈 텍스트 노드 생성
2289
+ react.fragment([]) // 빈 Fragment 생성
2290
+
2291
+ // 올바른 예
2292
+ react.none() // React null 반환
2293
+ ```
2294
+
2295
+ ---