create-mendix-widget-gleam 1.0.3 → 1.0.5

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