create-mendix-widget-gleam 2.0.21 → 3.0.1

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