django-wireview 0.2.1__py3-none-any.whl
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.
- django_wireview-0.2.1.dist-info/METADATA +1199 -0
- django_wireview-0.2.1.dist-info/RECORD +48 -0
- django_wireview-0.2.1.dist-info/WHEEL +4 -0
- wireview/__init__.py +117 -0
- wireview/apps.py +34 -0
- wireview/async_result.py +149 -0
- wireview/auto_broadcast.py +172 -0
- wireview/component.py +11 -0
- wireview/consumer.py +601 -0
- wireview/core/__init__.py +6 -0
- wireview/core/component.py +1378 -0
- wireview/core/meta.py +615 -0
- wireview/core/rendered.py +365 -0
- wireview/core/state.py +54 -0
- wireview/core/transport.py +132 -0
- wireview/debug/__init__.py +96 -0
- wireview/debug/sync_detector.py +284 -0
- wireview/event_transpiler.py +230 -0
- wireview/features/__init__.py +8 -0
- wireview/features/presence.py +471 -0
- wireview/features/streams.py +49 -0
- wireview/features/uploads.py +558 -0
- wireview/function_component.py +345 -0
- wireview/js.py +489 -0
- wireview/live_component.py +245 -0
- wireview/log.py +12 -0
- wireview/management/__init__.py +0 -0
- wireview/management/commands/__init__.py +0 -0
- wireview/management/commands/wireview_lsp.py +376 -0
- wireview/management/commands/wireview_stubs.py +830 -0
- wireview/repository.py +309 -0
- wireview/schemas.py +49 -0
- wireview/serializer.py +27 -0
- wireview/settings.py +46 -0
- wireview/slots.py +319 -0
- wireview/static/wireview/rendered.mjs +147 -0
- wireview/static/wireview/types.d.ts +52 -0
- wireview/static/wireview/wireview-boost.js +249 -0
- wireview/static/wireview/wireview.js +3124 -0
- wireview/static/wireview/wireview.min.js +26 -0
- wireview/template_engine.py +332 -0
- wireview/templates/wireview_header.html +9 -0
- wireview/templatetags/__init__.py +0 -0
- wireview/templatetags/wireview.py +831 -0
- wireview/testing.py +358 -0
- wireview/urls.py +19 -0
- wireview/utils.py +198 -0
- wireview/views.py +238 -0
|
@@ -0,0 +1,1199 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-wireview
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Brings LiveView from Phoenix framework into Django
|
|
5
|
+
Project-URL: Homepage, https://github.com/itda-work/django-wireview
|
|
6
|
+
Project-URL: Repository, https://github.com/itda-work/django-wireview
|
|
7
|
+
Author-email: Eddy Ernesto del Valle Pino <eddy@edelvalle.me>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Web Environment
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: Django :: 4.2
|
|
13
|
+
Classifier: Framework :: Django :: 5.0
|
|
14
|
+
Classifier: Framework :: Django :: 5.1
|
|
15
|
+
Classifier: Framework :: Django :: 6.0
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
24
|
+
Requires-Python: >=3.12
|
|
25
|
+
Requires-Dist: channels<5,>=4
|
|
26
|
+
Requires-Dist: django>=4.2
|
|
27
|
+
Requires-Dist: pydantic<3,>=2.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: channels-nats; extra == 'dev'
|
|
30
|
+
Requires-Dist: channels-redis; extra == 'dev'
|
|
31
|
+
Requires-Dist: cssselect; extra == 'dev'
|
|
32
|
+
Requires-Dist: daphne; extra == 'dev'
|
|
33
|
+
Requires-Dist: django-hmin; extra == 'dev'
|
|
34
|
+
Requires-Dist: django-stubs; extra == 'dev'
|
|
35
|
+
Requires-Dist: django-stubs-ext; extra == 'dev'
|
|
36
|
+
Requires-Dist: djlint; extra == 'dev'
|
|
37
|
+
Requires-Dist: ipython; extra == 'dev'
|
|
38
|
+
Requires-Dist: lxml; extra == 'dev'
|
|
39
|
+
Requires-Dist: pre-commit; extra == 'dev'
|
|
40
|
+
Requires-Dist: psutil>=7.2.2; extra == 'dev'
|
|
41
|
+
Requires-Dist: pyright; extra == 'dev'
|
|
42
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
43
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
44
|
+
Requires-Dist: pytest-django>=4.5; extra == 'dev'
|
|
45
|
+
Requires-Dist: pytest-playwright>=0.4.0; extra == 'dev'
|
|
46
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
47
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
48
|
+
Requires-Dist: twine; extra == 'dev'
|
|
49
|
+
Requires-Dist: uvicorn; extra == 'dev'
|
|
50
|
+
Requires-Dist: websockets; extra == 'dev'
|
|
51
|
+
Requires-Dist: whitenoise; extra == 'dev'
|
|
52
|
+
Description-Content-Type: text/markdown
|
|
53
|
+
|
|
54
|
+
# Wireview - Django를 위한 Phoenix LiveView
|
|
55
|
+
|
|
56
|
+
Wireview는 Django Channels를 사용하여 실시간 서버 렌더링 인터랙티브 UI를 구축할 수 있게 해주는 라이브러리입니다. Phoenix Framework의 LiveView와 유사합니다.
|
|
57
|
+
|
|
58
|
+

|
|
59
|
+
|
|
60
|
+
## 무엇이 포함되어 있나요?
|
|
61
|
+
|
|
62
|
+
VueJS나 ReactJS를 대체하는 것은 아니지만, Django의 모든 잠재력을 활용하여 인터랙티브한 프론트엔드를 만들 수 있습니다. 모든 것이 서버 사이드에서 렌더링되므로, 첫 번째 요청에서 의미 있는 정보가 포함된 인터페이스가 제공됩니다. Django 템플릿과 ORM의 모든 기능을 컴포넌트에서 직접 사용하고, 이벤트 구독을 통해 실시간으로 인터페이스를 업데이트할 수 있습니다.
|
|
63
|
+
|
|
64
|
+
**주요 기능:**
|
|
65
|
+
- 실시간 업데이트가 가능한 서버 사이드 렌더링 컴포넌트
|
|
66
|
+
- 자동 검증이 포함된 Pydantic 기반 상태 관리
|
|
67
|
+
- Django Channels를 통한 WebSocket 통신
|
|
68
|
+
- 효율적인 대역폭 사용을 위한 HTML diff
|
|
69
|
+
- 자동 UI 업데이트를 위한 모델 구독
|
|
70
|
+
- 대규모 리스트를 효율적으로 처리하는 Streams API
|
|
71
|
+
- 온라인 사용자 및 타이핑 표시를 위한 Presence 추적
|
|
72
|
+
- 진행률 추적이 가능한 파일 업로드
|
|
73
|
+
- Chart.js, Mapbox 등 서드파티 라이브러리 통합을 위한 JavaScript Hooks
|
|
74
|
+
|
|
75
|
+
## django-reactor 대비 개선 사항
|
|
76
|
+
|
|
77
|
+
Wireview는 [django-reactor](https://github.com/edelvalle/reactor)의 현대적인 진화 버전으로, 다음과 같은 중요한 개선 사항이 있습니다:
|
|
78
|
+
|
|
79
|
+
### 새로운 기능
|
|
80
|
+
|
|
81
|
+
| 기능 | reactor | wireview | 설명 |
|
|
82
|
+
|------|---------|----------|------|
|
|
83
|
+
| **Streams API** | - | ✅ | `stream()`, `stream_insert()`, `stream_delete()`로 메모리 효율적인 대규모 리스트 처리 |
|
|
84
|
+
| **Presence API** | - | ✅ | `PresenceMixin`, `PresenceTrackerMixin`으로 실시간 사용자 추적 및 타이핑 표시 |
|
|
85
|
+
| **파일 업로드** | - | ✅ | 진행률 추적, 매직 바이트 검증이 포함된 청크 업로드 |
|
|
86
|
+
| **AsyncResult** | - | ✅ | 비동기 작업을 위한 로딩/성공/에러 상태 관리 |
|
|
87
|
+
| **JS 명령어** | - | ✅ | `JS()` 빌더로 Phoenix LiveView.JS 스타일의 클라이언트 사이드 명령어 |
|
|
88
|
+
| **테스트 유틸리티** | - | ✅ | WebSocket 없이 쉽게 컴포넌트 테스트를 위한 `mount()` 유틸리티 |
|
|
89
|
+
| **디버그 도구** | - | ✅ | `wireview.debug`로 브라우저 콘솔 디버깅 |
|
|
90
|
+
| **JavaScript Hooks** | - | ✅ | Chart.js, Mapbox 등 서드파티 JavaScript 라이브러리 통합 |
|
|
91
|
+
|
|
92
|
+
### 아키텍처 개선
|
|
93
|
+
|
|
94
|
+
| 항목 | reactor | wireview |
|
|
95
|
+
|------|---------|----------|
|
|
96
|
+
| **Pydantic** | v1 (레거시) | v2 (최신) |
|
|
97
|
+
| **DOM Morphing** | morphdom | idiomorph (더 나은 속성 보존) |
|
|
98
|
+
| **Python** | ≥3.9 | ≥3.12 |
|
|
99
|
+
| **Django** | 3.2+ | 4.2, 5.0, 5.1, 6.0 |
|
|
100
|
+
| **모듈 구조** | 플랫 | 체계적 (`core/`, `features/`) |
|
|
101
|
+
|
|
102
|
+
### 새로운 컴포넌트 메서드
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
# 라이프사이클
|
|
106
|
+
async def leaving(self):
|
|
107
|
+
"""컴포넌트 연결 해제 시 호출 - 정리 훅"""
|
|
108
|
+
|
|
109
|
+
# UI 제어
|
|
110
|
+
await self.scroll_into_view(element_id, behavior="smooth")
|
|
111
|
+
await self.push_js(JS().set_value("input", ""))
|
|
112
|
+
|
|
113
|
+
# Streams
|
|
114
|
+
await self.stream("items", items)
|
|
115
|
+
await self.stream_insert("items", item, at=0)
|
|
116
|
+
await self.stream_delete("items", item_id)
|
|
117
|
+
|
|
118
|
+
# Presence
|
|
119
|
+
await self.presence_join()
|
|
120
|
+
await self.presence_set_typing(True)
|
|
121
|
+
|
|
122
|
+
# 비동기 로딩
|
|
123
|
+
self.data = await self.assign_async(fetch_data())
|
|
124
|
+
|
|
125
|
+
# JavaScript Hooks
|
|
126
|
+
await self.push_event("update_chart", {"data": [1, 2, 3]})
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### reactor에서 마이그레이션
|
|
130
|
+
|
|
131
|
+
대부분의 reactor 컴포넌트는 최소한의 변경으로 작동합니다:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
# reactor
|
|
135
|
+
from reactor.component import Component
|
|
136
|
+
|
|
137
|
+
class XCounter(Component):
|
|
138
|
+
_subscriptions = {"counter"}
|
|
139
|
+
|
|
140
|
+
# wireview (동일한 API)
|
|
141
|
+
from wireview.component import Component
|
|
142
|
+
|
|
143
|
+
class XCounter(Component):
|
|
144
|
+
_subscriptions = {"counter"}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
주요 차이점:
|
|
148
|
+
- 패키지 이름: `reactor` → `wireview`
|
|
149
|
+
- 설정 접두사: `REACTOR_*` → `WIREVIEW` dict
|
|
150
|
+
- 템플릿 태그: `{% load reactor %}` → `{% load wireview %}`
|
|
151
|
+
|
|
152
|
+
## 목차
|
|
153
|
+
|
|
154
|
+
- [django-reactor 대비 개선 사항](#django-reactor-대비-개선-사항)
|
|
155
|
+
- [설치 및 설정](#설치-및-설정)
|
|
156
|
+
- [빠른 시작](#빠른-시작)
|
|
157
|
+
- [컴포넌트 라이프사이클](#컴포넌트-라이프사이클)
|
|
158
|
+
- [이벤트 바인딩](#이벤트-바인딩)
|
|
159
|
+
- [URL 상태 관리](#url-상태-관리)
|
|
160
|
+
- [모델 구독](#모델-구독)
|
|
161
|
+
- [Streams API](#streams-api)
|
|
162
|
+
- [Presence API](#presence-api)
|
|
163
|
+
- [파일 업로드](#파일-업로드)
|
|
164
|
+
- [AsyncResult](#asyncresult와-비동기-작업)
|
|
165
|
+
- [JS 명령어 빌더](#js-명령어-빌더)
|
|
166
|
+
- [JavaScript Hooks](#javascript-hooks)
|
|
167
|
+
- [컴포넌트 API 레퍼런스](#컴포넌트-api-레퍼런스)
|
|
168
|
+
- [템플릿 태그 레퍼런스](#템플릿-태그-레퍼런스)
|
|
169
|
+
- [JavaScript API](#프론트엔드-api)
|
|
170
|
+
- [테스트](#컴포넌트-테스트)
|
|
171
|
+
- [디버그 도구](#디버그-도구)
|
|
172
|
+
- [설정](#설정)
|
|
173
|
+
|
|
174
|
+
## 설치 및 설정
|
|
175
|
+
|
|
176
|
+
Wireview는 Python ≥3.12과 Django ≥4.2가 필요합니다 (Django 4.2, 5.0, 5.1, 6.0 지원).
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
pip install django-wireview
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Wireview는 `django-channels`를 사용합니다. 기본 InMemory 채널 레이어는 프로세스 하나 안에서만 통하므로, 프로세스를 여러 개 띄우면 브로드캐스트가 **오류 없이** 같은 프로세스의 연결에만 닿습니다. 프로덕션에서는 프로세스를 잇는 레이어를 씁니다.
|
|
183
|
+
|
|
184
|
+
- [channels-nats](https://github.com/itda-work/channels-nats) — 이 프로젝트가 목표로 하는 레이어입니다. NATS 서버는 Go 바이너리 하나이고 Linux·macOS·Windows 네이티브 빌드가 있어, Redis 없이 SQLite 단일 서버와 Windows까지 같은 구성으로 갑니다.
|
|
185
|
+
- [channels_redis](https://channels.readthedocs.io/en/latest/topics/channel_layers.html) — Redis가 이미 있다면 이쪽입니다. 실측상 성능은 대등합니다.
|
|
186
|
+
|
|
187
|
+
배포 구성은 [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md), 두 레이어의 실측 비교는 [docs/design/transport-abstraction.md](./docs/design/transport-abstraction.md) §5-3에 있습니다.
|
|
188
|
+
|
|
189
|
+
Django 애플리케이션보다 먼저 `wireview`와 `channels`를 `INSTALLED_APPS`에 추가하세요:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
INSTALLED_APPS = [
|
|
193
|
+
'wireview',
|
|
194
|
+
'channels',
|
|
195
|
+
...
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
ASGI_APPLICATION = 'project_name.asgi.application'
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`project_name/asgi.py`를 수정하세요:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
import os
|
|
205
|
+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project_name.settings')
|
|
206
|
+
|
|
207
|
+
import django
|
|
208
|
+
django.setup()
|
|
209
|
+
|
|
210
|
+
from channels.auth import AuthMiddlewareStack
|
|
211
|
+
from channels.routing import ProtocolTypeRouter, URLRouter
|
|
212
|
+
from django.core.asgi import get_asgi_application
|
|
213
|
+
from wireview.urls import websocket_urlpatterns
|
|
214
|
+
|
|
215
|
+
application = ProtocolTypeRouter({
|
|
216
|
+
'http': get_asgi_application(),
|
|
217
|
+
'websocket': AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
|
|
218
|
+
})
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
템플릿에 wireview JavaScript를 포함하세요:
|
|
222
|
+
|
|
223
|
+
```html
|
|
224
|
+
{% load wireview %}
|
|
225
|
+
<!doctype html>
|
|
226
|
+
<html>
|
|
227
|
+
<head>
|
|
228
|
+
{% wireview_header %}
|
|
229
|
+
</head>
|
|
230
|
+
...
|
|
231
|
+
</html>
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## 빠른 시작
|
|
235
|
+
|
|
236
|
+
`x-counter.html` 템플릿을 생성하세요:
|
|
237
|
+
|
|
238
|
+
```html
|
|
239
|
+
{% load wireview %}
|
|
240
|
+
<div {% tag_header %}>
|
|
241
|
+
{{ amount }}
|
|
242
|
+
<button {% on 'click' 'inc' %}>+</button>
|
|
243
|
+
<button {% on 'click' 'dec' %}>-</button>
|
|
244
|
+
<button {% on 'click' 'set_to' amount=0 %}>reset</button>
|
|
245
|
+
</div>
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`live.py`에 컴포넌트를 생성하세요:
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
from wireview.component import Component
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class XCounter(Component):
|
|
255
|
+
_template_name = 'x-counter.html'
|
|
256
|
+
|
|
257
|
+
amount: int = 0
|
|
258
|
+
|
|
259
|
+
async def inc(self):
|
|
260
|
+
self.amount += 1
|
|
261
|
+
|
|
262
|
+
async def dec(self):
|
|
263
|
+
self.amount -= 1
|
|
264
|
+
|
|
265
|
+
async def set_to(self, amount: int):
|
|
266
|
+
self.amount = amount
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
뷰 템플릿에서 컴포넌트를 렌더링하세요:
|
|
270
|
+
|
|
271
|
+
```html
|
|
272
|
+
{% load wireview %}
|
|
273
|
+
<!doctype html>
|
|
274
|
+
<html>
|
|
275
|
+
<head>
|
|
276
|
+
{% wireview_header %}
|
|
277
|
+
</head>
|
|
278
|
+
<body>
|
|
279
|
+
{% component 'XCounter' %}
|
|
280
|
+
{% component 'XCounter' amount=100 %}
|
|
281
|
+
</body>
|
|
282
|
+
</html>
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## 컴포넌트 라이프사이클
|
|
286
|
+
|
|
287
|
+
### 초기화 및 렌더링
|
|
288
|
+
|
|
289
|
+
컴포넌트는 템플릿에 포함될 때 초기화됩니다:
|
|
290
|
+
|
|
291
|
+
```html
|
|
292
|
+
{% component 'Component' param1=1 param2=2 %}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
파라미터는 컴포넌트 인스턴스를 반환하는 `Component.new()`에 전달됩니다.
|
|
296
|
+
|
|
297
|
+
### 조인 (Joins)
|
|
298
|
+
|
|
299
|
+
컴포넌트가 프론트엔드에 도달하면 WebSocket을 통해 백엔드에 "조인"합니다. 직렬화된 상태가 백엔드로 전송되고, 백엔드는 컴포넌트를 재구성하고 `Component.joined()`를 호출합니다.
|
|
300
|
+
|
|
301
|
+
```python
|
|
302
|
+
class ChatRoom(Component):
|
|
303
|
+
async def joined(self):
|
|
304
|
+
# 컴포넌트가 WebSocket으로 연결될 때 호출됨
|
|
305
|
+
await self.broadcast(f"room.{self.room_id}", action="joined", user=self.username)
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### 퇴장 (Leaving)
|
|
309
|
+
|
|
310
|
+
컴포넌트가 파괴되거나 WebSocket 연결이 닫히면 `Component.leaving()`이 호출됩니다. 정리 작업에 사용하세요:
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
class ChatRoom(Component):
|
|
314
|
+
async def leaving(self):
|
|
315
|
+
# 컴포넌트 연결이 해제될 때 호출됨
|
|
316
|
+
await self.broadcast(f"room.{self.room_id}", action="left", user=self.username)
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### 사용자 이벤트
|
|
320
|
+
|
|
321
|
+
조인 후 컴포넌트는 `{% on %}` 템플릿 태그를 통해 사용자 이벤트를 받을 수 있습니다. 이벤트는 백엔드로 전송되고, 핸들러가 실행되며, 컴포넌트가 다시 렌더링됩니다.
|
|
322
|
+
|
|
323
|
+
### 모델 구독
|
|
324
|
+
|
|
325
|
+
컴포넌트는 모델 변경을 구독할 수 있습니다. 변경이 발생하면 `Component.mutation()`이 호출됩니다:
|
|
326
|
+
|
|
327
|
+
```python
|
|
328
|
+
class TodoList(Component):
|
|
329
|
+
_subscriptions = {"todo.item"} # todo 앱의 Item 모델 변경 구독
|
|
330
|
+
|
|
331
|
+
async def mutation(self, channel: str, action: ModelAction, instance):
|
|
332
|
+
# 구독한 모델이 변경될 때 호출됨
|
|
333
|
+
self.items = await self.load_items()
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### 알림
|
|
337
|
+
|
|
338
|
+
임의의 메시지에는 `broadcast()`와 `notification()`을 사용하세요:
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
# 발신자
|
|
342
|
+
await self.broadcast("chat.room.1", message="Hello!", sender=self.username)
|
|
343
|
+
|
|
344
|
+
# 수신자 ("chat.room.1" 구독 중)
|
|
345
|
+
async def notification(self, channel: str, **kwargs):
|
|
346
|
+
message = kwargs.get("message")
|
|
347
|
+
sender = kwargs.get("sender")
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
## 이벤트 바인딩
|
|
351
|
+
|
|
352
|
+
### 기본 문법
|
|
353
|
+
|
|
354
|
+
```html
|
|
355
|
+
{% on <event.modifiers> <handler> [kwargs] %}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
예제:
|
|
359
|
+
|
|
360
|
+
```html
|
|
361
|
+
<button {% on "click" "increment" %}>+1</button>
|
|
362
|
+
<button {% on "click" "increment" amount=5 %}>+5</button>
|
|
363
|
+
<button {% on "click.prevent" "submit" %}>제출</button>
|
|
364
|
+
<input {% on "keypress.enter" "search" %}>
|
|
365
|
+
<input {% on "input.debounce.300" "filter" %}>
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### 사용 가능한 수정자
|
|
369
|
+
|
|
370
|
+
| 수정자 | 설명 |
|
|
371
|
+
|--------|------|
|
|
372
|
+
| `prevent` | `event.preventDefault()` 호출 |
|
|
373
|
+
| `stop` | `event.stopPropagation()` 호출 |
|
|
374
|
+
| `ctrl`, `alt`, `shift`, `meta` | 수정 키 필요 |
|
|
375
|
+
| `debounce.<ms>` | 이벤트 디바운스 (예: `debounce.300`) |
|
|
376
|
+
| `throttle.<ms>` | 이벤트 쓰로틀 (예: `throttle.100`) |
|
|
377
|
+
| `enter`, `tab`, `delete`, `backspace`, `space` | 키 별칭 |
|
|
378
|
+
| `up`, `down`, `left`, `right` | 화살표 키 별칭 |
|
|
379
|
+
| `key.<keycode>` | 특정 키 (예: `key.escape`) |
|
|
380
|
+
| `inlinejs` | 핸들러를 리터럴 JavaScript로 처리 |
|
|
381
|
+
|
|
382
|
+
### 암시적 인자
|
|
383
|
+
|
|
384
|
+
컴포넌트 내의 폼 입력은 자동으로 인자로 전송됩니다:
|
|
385
|
+
|
|
386
|
+
```html
|
|
387
|
+
<div {% tag_header %}>
|
|
388
|
+
<input name="query">
|
|
389
|
+
<button {% on "click" "search" %}>검색</button>
|
|
390
|
+
</div>
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
```python
|
|
394
|
+
async def search(self, query: str):
|
|
395
|
+
self.results = await self.do_search(query)
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
## URL 상태 관리
|
|
399
|
+
|
|
400
|
+
URL 쿼리 문자열에 컴포넌트 상태를 저장하세요:
|
|
401
|
+
|
|
402
|
+
```python
|
|
403
|
+
class SearchList(Component):
|
|
404
|
+
query: str = ""
|
|
405
|
+
|
|
406
|
+
@classmethod
|
|
407
|
+
def new(cls, wire, **kwargs):
|
|
408
|
+
kwargs.setdefault("query", wire.params.get("query", ""))
|
|
409
|
+
return cls(wire=wire, **kwargs)
|
|
410
|
+
|
|
411
|
+
async def filter_results(self, query: str):
|
|
412
|
+
self.query = query
|
|
413
|
+
self.wire.params["query"] = query # URL 업데이트
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
복잡한 값에는 `.json` 접미사를 사용하세요:
|
|
417
|
+
|
|
418
|
+
```python
|
|
419
|
+
class TreeView(Component):
|
|
420
|
+
@classmethod
|
|
421
|
+
def new(cls, wire, id: str, **kwargs):
|
|
422
|
+
kwargs["expanded"] = id in wire.params.get("expanded.json", [])
|
|
423
|
+
return cls(wire=wire, id=id, **kwargs)
|
|
424
|
+
|
|
425
|
+
async def toggle_expanded(self):
|
|
426
|
+
self.expanded = not self.expanded
|
|
427
|
+
expanded = self.wire.params.setdefault("expanded.json", [])
|
|
428
|
+
if self.expanded:
|
|
429
|
+
expanded.append(self.id)
|
|
430
|
+
elif self.id in expanded:
|
|
431
|
+
expanded.remove(self.id)
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
## 모델 구독
|
|
435
|
+
|
|
436
|
+
자동 UI 업데이트를 위해 Django 모델 변경을 구독하세요:
|
|
437
|
+
|
|
438
|
+
```python
|
|
439
|
+
class TodoList(Component):
|
|
440
|
+
_subscriptions = {"todo.item"} # {app_label}.{model_name} 형식
|
|
441
|
+
|
|
442
|
+
async def mutation(self, channel: str, action: ModelAction, instance):
|
|
443
|
+
if action == ModelAction.CREATED:
|
|
444
|
+
self.items.append(instance)
|
|
445
|
+
elif action == ModelAction.DELETED:
|
|
446
|
+
self.items = [i for i in self.items if i.id != instance.id]
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
설정에서 자동 브로드캐스트를 활성화하세요:
|
|
450
|
+
|
|
451
|
+
```python
|
|
452
|
+
WIREVIEW = {
|
|
453
|
+
"AUTO_BROADCAST": AutoBroadcast(
|
|
454
|
+
model=True, # 모델 변경 시 브로드캐스트
|
|
455
|
+
model_pk=True, # 채널 이름에 PK 포함
|
|
456
|
+
),
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
## Streams API
|
|
461
|
+
|
|
462
|
+
Streams는 아이템을 개별적으로 렌더링하고 증분 업데이트를 전송하여 대규모 리스트를 메모리 효율적으로 처리합니다.
|
|
463
|
+
|
|
464
|
+
### 기본 사용법
|
|
465
|
+
|
|
466
|
+
스트림 컨테이너가 있는 템플릿:
|
|
467
|
+
|
|
468
|
+
```html
|
|
469
|
+
{% load wireview %}
|
|
470
|
+
<div {% tag_header %}>
|
|
471
|
+
<ul wire-stream="messages">
|
|
472
|
+
{% for message in messages %}
|
|
473
|
+
{% include "chat/message_item.html" %}
|
|
474
|
+
{% endfor %}
|
|
475
|
+
</ul>
|
|
476
|
+
</div>
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
아이템 템플릿 (`chat/message_item.html`):
|
|
480
|
+
|
|
481
|
+
```html
|
|
482
|
+
<li id="messages-{{ message.pk }}">
|
|
483
|
+
<strong>{{ message.sender }}:</strong> {{ message.text }}
|
|
484
|
+
</li>
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
컴포넌트:
|
|
488
|
+
|
|
489
|
+
```python
|
|
490
|
+
class MessageList(Component):
|
|
491
|
+
_template_name = "chat/message_list.html"
|
|
492
|
+
messages: list = []
|
|
493
|
+
|
|
494
|
+
async def joined(self):
|
|
495
|
+
# 스트림으로 초기 로드
|
|
496
|
+
messages = await Message.objects.order_by('-created')[:50]
|
|
497
|
+
await self.stream("messages", reversed(messages))
|
|
498
|
+
|
|
499
|
+
async def add_message(self, text: str):
|
|
500
|
+
message = await Message.objects.acreate(sender=self.user, text=text)
|
|
501
|
+
await self.stream_insert("messages", message, at=-1) # 끝에 추가
|
|
502
|
+
await self.scroll_into_view(f"messages-{message.pk}")
|
|
503
|
+
|
|
504
|
+
async def delete_message(self, message_id: int):
|
|
505
|
+
await Message.objects.filter(id=message_id).adelete()
|
|
506
|
+
await self.stream_delete("messages", message_id)
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
### Stream 메서드
|
|
510
|
+
|
|
511
|
+
| 메서드 | 설명 |
|
|
512
|
+
|--------|------|
|
|
513
|
+
| `stream(name, items)` | 스트림 초기화/리셋 |
|
|
514
|
+
| `stream_insert(name, item, at=-1)` | 아이템 삽입 (-1=끝, 0=처음, n=인덱스) |
|
|
515
|
+
| `stream_delete(name, dom_id)` | DOM ID 또는 PK로 아이템 삭제 |
|
|
516
|
+
|
|
517
|
+
### DOM ID 규칙
|
|
518
|
+
|
|
519
|
+
기본적으로 DOM ID는 `{stream_name}-{item.pk}` 패턴을 따릅니다. 커스텀 ID 함수:
|
|
520
|
+
|
|
521
|
+
```python
|
|
522
|
+
await self.stream("items", items, dom_id=lambda item: f"item-{item.uuid}")
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
### 커스텀 아이템 템플릿
|
|
526
|
+
|
|
527
|
+
```python
|
|
528
|
+
await self.stream_insert("messages", message, template="chat/special_message.html")
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
## Presence API
|
|
532
|
+
|
|
533
|
+
온라인 사용자와 타이핑 표시를 실시간으로 추적합니다.
|
|
534
|
+
|
|
535
|
+
### PresenceMixin (프로듀서)
|
|
536
|
+
|
|
537
|
+
자신의 프레즌스를 브로드캐스트하는 컴포넌트용:
|
|
538
|
+
|
|
539
|
+
```python
|
|
540
|
+
from wireview.component import Component
|
|
541
|
+
from wireview.features.presence import PresenceMixin
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class ChatInput(PresenceMixin, Component):
|
|
545
|
+
_template_name = "chat/input.html"
|
|
546
|
+
room_id: int
|
|
547
|
+
username: str
|
|
548
|
+
|
|
549
|
+
def _presence_topic(self) -> str:
|
|
550
|
+
return f"room.{self.room_id}"
|
|
551
|
+
|
|
552
|
+
def _presence_user_id(self) -> str:
|
|
553
|
+
return str(self.user_id)
|
|
554
|
+
|
|
555
|
+
def _presence_username(self) -> str:
|
|
556
|
+
return self.username
|
|
557
|
+
|
|
558
|
+
async def joined(self):
|
|
559
|
+
await self.presence_join()
|
|
560
|
+
|
|
561
|
+
async def leaving(self):
|
|
562
|
+
await self.presence_leave()
|
|
563
|
+
|
|
564
|
+
async def on_typing(self):
|
|
565
|
+
await self.presence_set_typing(True) # 3초 후 자동 해제
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
### PresenceTrackerMixin (컨슈머)
|
|
569
|
+
|
|
570
|
+
다른 사용자의 프레즌스를 표시하는 컴포넌트용:
|
|
571
|
+
|
|
572
|
+
```python
|
|
573
|
+
from wireview.features.presence import PresenceTrackerMixin
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
class OnlineUsers(PresenceTrackerMixin, Component):
|
|
577
|
+
_template_name = "chat/online_users.html"
|
|
578
|
+
room_id: int
|
|
579
|
+
username: str
|
|
580
|
+
|
|
581
|
+
def _presence_topic(self) -> str:
|
|
582
|
+
return f"room.{self.room_id}"
|
|
583
|
+
|
|
584
|
+
def _presence_my_user_id(self) -> str:
|
|
585
|
+
return str(self.user_id)
|
|
586
|
+
|
|
587
|
+
@property
|
|
588
|
+
def _subscriptions(self):
|
|
589
|
+
return {self._presence_channel()}
|
|
590
|
+
|
|
591
|
+
async def joined(self):
|
|
592
|
+
await self.presence_track_self(username=self.username)
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
템플릿:
|
|
596
|
+
|
|
597
|
+
```html
|
|
598
|
+
{% load wireview %}
|
|
599
|
+
<div {% tag_header %}>
|
|
600
|
+
<h3>온라인 ({{ this.presence_online_count }})</h3>
|
|
601
|
+
<ul>
|
|
602
|
+
{% for user in this.presence_users %}
|
|
603
|
+
<li>
|
|
604
|
+
{{ user.username }}
|
|
605
|
+
{% if user.is_typing %}<span class="typing">입력 중...</span>{% endif %}
|
|
606
|
+
</li>
|
|
607
|
+
{% endfor %}
|
|
608
|
+
</ul>
|
|
609
|
+
</div>
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
### Presence 속성
|
|
613
|
+
|
|
614
|
+
| 속성 | 설명 |
|
|
615
|
+
|------|------|
|
|
616
|
+
| `presence_users` | 모든 추적된 사용자 목록 |
|
|
617
|
+
| `presence_online_count` | 온라인 사용자 수 |
|
|
618
|
+
| `presence_typing_users` | 현재 타이핑 중인 사용자 목록 |
|
|
619
|
+
|
|
620
|
+
### 설정
|
|
621
|
+
|
|
622
|
+
```python
|
|
623
|
+
from wireview.features.presence import PresenceConfig
|
|
624
|
+
|
|
625
|
+
class MyComponent(PresenceMixin, Component):
|
|
626
|
+
_presence_config = PresenceConfig(
|
|
627
|
+
typing_timeout=3.0, # 타이핑 자동 해제까지 초
|
|
628
|
+
sync_on_join=True, # 조인 시 다른 사용자에게 동기화 요청
|
|
629
|
+
channel_prefix="presence",
|
|
630
|
+
)
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
## 파일 업로드
|
|
634
|
+
|
|
635
|
+
진행률 추적과 검증이 포함된 파일 업로드를 처리합니다.
|
|
636
|
+
|
|
637
|
+
### 기본 설정
|
|
638
|
+
|
|
639
|
+
```python
|
|
640
|
+
from wireview.component import Component
|
|
641
|
+
from wireview.features.uploads import UploadConfig
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
class FileUploader(Component):
|
|
645
|
+
_template_name = "uploader.html"
|
|
646
|
+
|
|
647
|
+
async def joined(self):
|
|
648
|
+
self.allow_upload(UploadConfig(
|
|
649
|
+
name="avatar",
|
|
650
|
+
accept=[".jpg", ".png", ".gif"],
|
|
651
|
+
max_file_size=5 * 1024 * 1024, # 5MB
|
|
652
|
+
max_entries=1,
|
|
653
|
+
))
|
|
654
|
+
|
|
655
|
+
async def save_avatar(self):
|
|
656
|
+
for upload in self.consume_uploads("avatar"):
|
|
657
|
+
path = await upload.save_to("avatars/", filename=f"{self.user_id}.jpg")
|
|
658
|
+
self.avatar_url = path
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
템플릿:
|
|
662
|
+
|
|
663
|
+
```html
|
|
664
|
+
{% load wireview %}
|
|
665
|
+
<div {% tag_header %}>
|
|
666
|
+
<input type="file" wire-upload="avatar" accept=".jpg,.png,.gif">
|
|
667
|
+
|
|
668
|
+
{% for entry in this.uploads.avatar %}
|
|
669
|
+
<div class="upload-entry">
|
|
670
|
+
{{ entry.client_name }} - {{ entry.progress }}%
|
|
671
|
+
{% if entry.errors %}
|
|
672
|
+
<span class="error">{{ entry.errors|join:", " }}</span>
|
|
673
|
+
{% endif %}
|
|
674
|
+
</div>
|
|
675
|
+
{% endfor %}
|
|
676
|
+
|
|
677
|
+
<button {% on "click" "save_avatar" %}>저장</button>
|
|
678
|
+
</div>
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
### UploadConfig 옵션
|
|
682
|
+
|
|
683
|
+
| 옵션 | 기본값 | 설명 |
|
|
684
|
+
|------|--------|------|
|
|
685
|
+
| `name` | 필수 | 업로드 필드 식별자 |
|
|
686
|
+
| `accept` | `[]` | 허용된 확장자 (예: `[".jpg", ".png"]`) |
|
|
687
|
+
| `max_entries` | `1` | 최대 동시 업로드 수 |
|
|
688
|
+
| `max_file_size` | `10MB` | 최대 파일 크기 (바이트) |
|
|
689
|
+
| `chunk_size` | `64KB` | 업로드 청크 크기 |
|
|
690
|
+
| `auto_upload` | `True` | 선택 시 즉시 업로드 시작 |
|
|
691
|
+
|
|
692
|
+
### ConsumedUpload 메서드
|
|
693
|
+
|
|
694
|
+
| 메서드 | 설명 |
|
|
695
|
+
|--------|------|
|
|
696
|
+
| `read()` | 전체 파일을 메모리로 읽기 |
|
|
697
|
+
| `open(mode="rb")` | 파일 핸들 열기 |
|
|
698
|
+
| `save_to(directory, filename=None)` | Django 스토리지에 저장 |
|
|
699
|
+
| `name` | 원본 파일명 |
|
|
700
|
+
| `size` | 파일 크기 (바이트) |
|
|
701
|
+
| `content_type` | MIME 타입 |
|
|
702
|
+
|
|
703
|
+
### 보안
|
|
704
|
+
|
|
705
|
+
Wireview는 확장자 위조를 방지하기 위해 저장 전에 파일 시그니처(매직 바이트)를 검증합니다.
|
|
706
|
+
|
|
707
|
+
## AsyncResult와 비동기 작업
|
|
708
|
+
|
|
709
|
+
로딩/에러 상태와 함께 비동기 데이터 로딩을 처리합니다:
|
|
710
|
+
|
|
711
|
+
```python
|
|
712
|
+
from wireview import Component, AsyncResult
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
class Dashboard(Component):
|
|
716
|
+
_template_name = "dashboard.html"
|
|
717
|
+
stats: AsyncResult = None
|
|
718
|
+
|
|
719
|
+
async def joined(self):
|
|
720
|
+
self.stats = await self.assign_async(self.load_stats())
|
|
721
|
+
|
|
722
|
+
async def load_stats(self):
|
|
723
|
+
return await Stats.objects.aget()
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
템플릿:
|
|
727
|
+
|
|
728
|
+
```html
|
|
729
|
+
{% if stats.loading %}
|
|
730
|
+
<div class="spinner">로딩 중...</div>
|
|
731
|
+
{% elif stats.ok %}
|
|
732
|
+
<div>총계: {{ stats.result.total }}</div>
|
|
733
|
+
{% elif stats.failed %}
|
|
734
|
+
<div class="error">{{ stats.error_message }}</div>
|
|
735
|
+
{% endif %}
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
### AsyncResult 속성
|
|
739
|
+
|
|
740
|
+
| 속성 | 설명 |
|
|
741
|
+
|------|------|
|
|
742
|
+
| `loading` | 작업 진행 중이면 True |
|
|
743
|
+
| `ok` | 작업 성공이면 True |
|
|
744
|
+
| `failed` | 작업 실패면 True |
|
|
745
|
+
| `done` | 완료되면 True (성공 또는 실패) |
|
|
746
|
+
| `result` | 결과 값 (성공 시) |
|
|
747
|
+
| `error` | 예외 (실패 시) |
|
|
748
|
+
| `error_message` | 에러의 문자열 표현 |
|
|
749
|
+
|
|
750
|
+
### AsyncResult 메서드
|
|
751
|
+
|
|
752
|
+
| 메서드 | 설명 |
|
|
753
|
+
|--------|------|
|
|
754
|
+
| `map(func)` | 결과 값 변환 |
|
|
755
|
+
| `get_or(default)` | 결과 또는 기본값 가져오기 |
|
|
756
|
+
| `get_or_raise()` | 결과 가져오기 또는 에러 발생 |
|
|
757
|
+
|
|
758
|
+
## JS 명령어 빌더
|
|
759
|
+
|
|
760
|
+
서버 왕복 없이 실행되는 클라이언트 사이드 명령어를 빌드합니다:
|
|
761
|
+
|
|
762
|
+
```python
|
|
763
|
+
from wireview import JS
|
|
764
|
+
|
|
765
|
+
# 템플릿에서
|
|
766
|
+
<button {% on "click" JS().toggle("#modal") %}>모달 토글</button>
|
|
767
|
+
|
|
768
|
+
# 명령어 체이닝
|
|
769
|
+
<button {% on "click" JS().add_class("#btn", "loading").push("save") %}>
|
|
770
|
+
저장
|
|
771
|
+
</button>
|
|
772
|
+
|
|
773
|
+
# 트랜지션과 함께
|
|
774
|
+
<div {% on "click" JS().hide(transition=("fade-out", 300)) %}></div>
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
### 서버에서 JS 푸시
|
|
778
|
+
|
|
779
|
+
이벤트 핸들러에서 JS 명령어 전송:
|
|
780
|
+
|
|
781
|
+
```python
|
|
782
|
+
async def clear_input(self):
|
|
783
|
+
await self.push_js(JS().set_value("input[name=search]", ""))
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
### 사용 가능한 명령어
|
|
787
|
+
|
|
788
|
+
**표시:**
|
|
789
|
+
- `show(selector, transition=None, display=None)`
|
|
790
|
+
- `hide(selector, transition=None)`
|
|
791
|
+
- `toggle(selector, show=None, hide=None)`
|
|
792
|
+
|
|
793
|
+
**CSS 클래스:**
|
|
794
|
+
- `add_class(selector, classes, transition=None)`
|
|
795
|
+
- `remove_class(selector, classes, transition=None)`
|
|
796
|
+
- `toggle_class(selector, classes, transition=None)`
|
|
797
|
+
|
|
798
|
+
**속성:**
|
|
799
|
+
- `set_attr(selector, attr, value)`
|
|
800
|
+
- `remove_attr(selector, attr)`
|
|
801
|
+
- `set_value(selector, value)` - 입력 값 설정
|
|
802
|
+
|
|
803
|
+
**포커스:**
|
|
804
|
+
- `focus(selector)`
|
|
805
|
+
- `focus_first(selector, input_only=False)`
|
|
806
|
+
|
|
807
|
+
**트랜지션:**
|
|
808
|
+
- `transition(selector, classes, time=None)`
|
|
809
|
+
|
|
810
|
+
**서버 통신:**
|
|
811
|
+
- `push(event, value=None, target=None)` - 서버로 이벤트 전송
|
|
812
|
+
|
|
813
|
+
**네비게이션:**
|
|
814
|
+
- `navigate(url, replace=False)`
|
|
815
|
+
- `dispatch(event, to=None, detail=None, bubbles=True)`
|
|
816
|
+
|
|
817
|
+
### 로딩 클래스
|
|
818
|
+
|
|
819
|
+
서버 요청 중 다음 클래스가 자동으로 추가됩니다:
|
|
820
|
+
|
|
821
|
+
| 클래스 | 설명 |
|
|
822
|
+
|--------|------|
|
|
823
|
+
| `wireview-loading` | 모든 요청 중에 추가 |
|
|
824
|
+
| `wireview-click-loading` | 클릭 이벤트에 추가 |
|
|
825
|
+
| `wireview-submit-loading` | 제출 이벤트에 추가 |
|
|
826
|
+
|
|
827
|
+
```css
|
|
828
|
+
.wireview-loading {
|
|
829
|
+
opacity: 0.5;
|
|
830
|
+
pointer-events: none;
|
|
831
|
+
}
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
## JavaScript Hooks
|
|
835
|
+
|
|
836
|
+
JavaScript Hooks를 사용하면 Chart.js, Mapbox, CodeMirror 등 서드파티 JavaScript 라이브러리를 wireview 컴포넌트와 통합할 수 있습니다. Phoenix LiveView의 Hooks API를 따릅니다.
|
|
837
|
+
|
|
838
|
+
### Hook 정의
|
|
839
|
+
|
|
840
|
+
```javascript
|
|
841
|
+
window.wireview.hooks.ChartHook = {
|
|
842
|
+
mounted() {
|
|
843
|
+
// 엘리먼트가 페이지에 추가되면 호출
|
|
844
|
+
const config = JSON.parse(this.el.dataset.config);
|
|
845
|
+
this.chart = new Chart(this.el, config);
|
|
846
|
+
},
|
|
847
|
+
|
|
848
|
+
updated() {
|
|
849
|
+
// DOM 업데이트 후 호출
|
|
850
|
+
this.chart.update();
|
|
851
|
+
},
|
|
852
|
+
|
|
853
|
+
destroyed() {
|
|
854
|
+
// 엘리먼트가 제거되면 호출
|
|
855
|
+
this.chart.destroy();
|
|
856
|
+
},
|
|
857
|
+
|
|
858
|
+
disconnected() {
|
|
859
|
+
// WebSocket 연결이 끊기면 호출
|
|
860
|
+
this.el.classList.add('offline');
|
|
861
|
+
},
|
|
862
|
+
|
|
863
|
+
reconnected() {
|
|
864
|
+
// WebSocket이 재연결되면 호출
|
|
865
|
+
this.el.classList.remove('offline');
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
### 템플릿에서 사용
|
|
871
|
+
|
|
872
|
+
```html
|
|
873
|
+
<div wire-hook="ChartHook" data-config='{"type": "line", "data": {...}}'>
|
|
874
|
+
</div>
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
### 서버로 이벤트 전송 (pushEvent)
|
|
878
|
+
|
|
879
|
+
```javascript
|
|
880
|
+
window.wireview.hooks.InfiniteScroll = {
|
|
881
|
+
mounted() {
|
|
882
|
+
this.page = 1;
|
|
883
|
+
this.observer = new IntersectionObserver(entries => {
|
|
884
|
+
if (entries[0].isIntersecting) {
|
|
885
|
+
this.loadMore();
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
this.observer.observe(this.el.querySelector('.sentinel'));
|
|
889
|
+
},
|
|
890
|
+
|
|
891
|
+
loadMore() {
|
|
892
|
+
this.pushEvent("load_more", { page: this.page }, (response) => {
|
|
893
|
+
if (response.hasMore) {
|
|
894
|
+
this.page++;
|
|
895
|
+
} else {
|
|
896
|
+
this.observer.disconnect();
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
},
|
|
900
|
+
|
|
901
|
+
destroyed() {
|
|
902
|
+
this.observer.disconnect();
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
### 서버에서 이벤트 받기 (handleEvent)
|
|
908
|
+
|
|
909
|
+
```javascript
|
|
910
|
+
window.wireview.hooks.Notification = {
|
|
911
|
+
mounted() {
|
|
912
|
+
this.handleEvent("show_toast", ({ message, type }) => {
|
|
913
|
+
this.showToast(message, type);
|
|
914
|
+
});
|
|
915
|
+
},
|
|
916
|
+
|
|
917
|
+
showToast(message, type) {
|
|
918
|
+
// 토스트 표시 구현
|
|
919
|
+
}
|
|
920
|
+
};
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
### 서버 사이드 핸들러
|
|
924
|
+
|
|
925
|
+
```python
|
|
926
|
+
class Dashboard(Component):
|
|
927
|
+
_template_name = "dashboard.html"
|
|
928
|
+
|
|
929
|
+
async def handle_hook_event(self, hook_id: str, event: str, payload: dict):
|
|
930
|
+
"""JavaScript Hook에서 보낸 이벤트 처리"""
|
|
931
|
+
if event == "load_more":
|
|
932
|
+
items = await self.fetch_items(payload.get("page", 1))
|
|
933
|
+
return {"hasMore": len(items) == 20}
|
|
934
|
+
return None
|
|
935
|
+
|
|
936
|
+
async def update_chart(self, data: list):
|
|
937
|
+
"""모든 Hook에 이벤트 전송"""
|
|
938
|
+
await self.push_event("update_data", {"values": data})
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
### Hook 라이프사이클
|
|
942
|
+
|
|
943
|
+
| 콜백 | 호출 시점 |
|
|
944
|
+
|------|----------|
|
|
945
|
+
| `mounted()` | 엘리먼트가 조인되고 첫 렌더링 후 |
|
|
946
|
+
| `beforeUpdate()` | DOM morph 전 (동기) |
|
|
947
|
+
| `updated()` | DOM morph 완료 후 |
|
|
948
|
+
| `destroyed()` | 엘리먼트가 DOM에서 제거될 때 |
|
|
949
|
+
| `disconnected()` | WebSocket 연결이 닫힐 때 |
|
|
950
|
+
| `reconnected()` | WebSocket이 재연결될 때 |
|
|
951
|
+
|
|
952
|
+
### Hook 컨텍스트
|
|
953
|
+
|
|
954
|
+
| 속성/메서드 | 설명 |
|
|
955
|
+
|-------------|------|
|
|
956
|
+
| `this.el` | Hook이 연결된 DOM 엘리먼트 |
|
|
957
|
+
| `this.pushEvent(event, payload, callback)` | 서버로 이벤트 전송 |
|
|
958
|
+
| `this.handleEvent(event, callback)` | 서버 이벤트 핸들러 등록 |
|
|
959
|
+
|
|
960
|
+
자세한 내용은 [JavaScript Hooks 문서](docs/features/hooks.md)를 참조하세요.
|
|
961
|
+
|
|
962
|
+
## 컴포넌트 API 레퍼런스
|
|
963
|
+
|
|
964
|
+
### 클래스 속성
|
|
965
|
+
|
|
966
|
+
| 속성 | 기본값 | 설명 |
|
|
967
|
+
|------|--------|------|
|
|
968
|
+
| `_template_name` | 필수 | 템플릿 경로 |
|
|
969
|
+
| `_exclude_fields` | `{"user", "wire"}` | 직렬화에서 제외할 필드 |
|
|
970
|
+
| `_subscriptions` | `set()` | 구독할 채널 |
|
|
971
|
+
|
|
972
|
+
### 라이프사이클 메서드
|
|
973
|
+
|
|
974
|
+
| 메서드 | 설명 |
|
|
975
|
+
|--------|------|
|
|
976
|
+
| `new(cls, wire, **kwargs)` | 인스턴스 생성 클래스 메서드 |
|
|
977
|
+
| `joined()` | 컴포넌트가 WebSocket으로 연결될 때 호출 |
|
|
978
|
+
| `leaving()` | 컴포넌트 연결이 해제될 때 호출 |
|
|
979
|
+
| `mutation(channel, action, instance)` | 모델 변경 시 호출 |
|
|
980
|
+
| `notification(channel, **kwargs)` | 브로드캐스트 메시지 시 호출 |
|
|
981
|
+
| `handle_hook_event(hook_id, event, payload)` | Hook 이벤트 수신 시 호출 |
|
|
982
|
+
|
|
983
|
+
### 렌더 제어
|
|
984
|
+
|
|
985
|
+
| 메서드 | 설명 |
|
|
986
|
+
|--------|------|
|
|
987
|
+
| `skip_render()` | 다음 렌더 사이클 건너뛰기 |
|
|
988
|
+
| `send_render()` | 즉시 렌더 강제 |
|
|
989
|
+
| `force_render()` | 다시 렌더링 표시 |
|
|
990
|
+
| `freeze()` | 모든 향후 렌더 방지 |
|
|
991
|
+
|
|
992
|
+
### 액션
|
|
993
|
+
|
|
994
|
+
| 메서드 | 설명 |
|
|
995
|
+
|--------|------|
|
|
996
|
+
| `destroy()` | 인터페이스에서 컴포넌트 제거 |
|
|
997
|
+
| `focus_on(selector)` | 요소에 포커스 |
|
|
998
|
+
| `scroll_into_view(element_id, behavior="auto", block="start", inline="nearest")` | 요소를 뷰로 스크롤 |
|
|
999
|
+
| `push_js(js)` | 클라이언트에서 JS 명령어 실행 |
|
|
1000
|
+
| `dom(action, id, component_or_template, **kwargs)` | DOM 조작 |
|
|
1001
|
+
| `deffer(func, *args, **kwargs)` | 함수 실행 지연 |
|
|
1002
|
+
| `push_event(event, payload, hook_id=None)` | Hook에 이벤트 전송 |
|
|
1003
|
+
|
|
1004
|
+
### 브로드캐스팅
|
|
1005
|
+
|
|
1006
|
+
| 메서드 | 설명 |
|
|
1007
|
+
|--------|------|
|
|
1008
|
+
| `broadcast(channel, **kwargs)` | 채널로 메시지 전송 (`joined()`에서 큐잉) |
|
|
1009
|
+
| `abroadcast(channel, **kwargs)` | 즉시 메시지 전송 (비동기) |
|
|
1010
|
+
|
|
1011
|
+
### 네비게이션
|
|
1012
|
+
|
|
1013
|
+
| 메서드 | 설명 |
|
|
1014
|
+
|--------|------|
|
|
1015
|
+
| `wire.redirect_to(url, **kwargs)` | 네비게이트하고 새 페이지 가져오기 |
|
|
1016
|
+
| `wire.replace_to(url, **kwargs)` | 현재 URL 교체 |
|
|
1017
|
+
| `wire.push_to(url, **kwargs)` | 가져오기 없이 URL 푸시 |
|
|
1018
|
+
|
|
1019
|
+
### Streams
|
|
1020
|
+
|
|
1021
|
+
| 메서드 | 설명 |
|
|
1022
|
+
|--------|------|
|
|
1023
|
+
| `stream(name, items, template=None, dom_id=None)` | 스트림 초기화/리셋 |
|
|
1024
|
+
| `stream_insert(name, item, at=-1, template=None, dom_id=None)` | 아이템 삽입 |
|
|
1025
|
+
| `stream_delete(name, dom_id)` | 아이템 삭제 |
|
|
1026
|
+
|
|
1027
|
+
### Uploads
|
|
1028
|
+
|
|
1029
|
+
| 메서드 | 설명 |
|
|
1030
|
+
|--------|------|
|
|
1031
|
+
| `allow_upload(config)` | 업로드 설정 등록 |
|
|
1032
|
+
| `consume_uploads(name)` | 완료된 업로드 가져오기 |
|
|
1033
|
+
| `cancel_upload(name, ref)` | 업로드 취소 |
|
|
1034
|
+
|
|
1035
|
+
## 템플릿 태그 레퍼런스
|
|
1036
|
+
|
|
1037
|
+
```html
|
|
1038
|
+
{% load wireview %}
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
| 태그 | 설명 |
|
|
1042
|
+
|------|------|
|
|
1043
|
+
| `{% wireview_header %}` | 필요한 JavaScript 포함 (~10KB 압축) |
|
|
1044
|
+
| `{% component 'Name' kwarg=value %}` | 컴포넌트 렌더링 |
|
|
1045
|
+
| `{% on 'event.modifiers' 'handler' kwargs %}` | 이벤트 핸들러 바인딩 |
|
|
1046
|
+
| `{% tag_header %}` | 루트 요소에 컴포넌트 속성 추가 |
|
|
1047
|
+
| `{% cond {'hidden': is_hidden} %}` | 조건부 속성 |
|
|
1048
|
+
| `{% class {'active': is_active} %}` | 조건부 CSS 클래스 |
|
|
1049
|
+
|
|
1050
|
+
## 프론트엔드 API
|
|
1051
|
+
|
|
1052
|
+
```javascript
|
|
1053
|
+
// 컴포넌트에 이벤트 전송
|
|
1054
|
+
wireview.send(element, 'handler_name', {arg1: value1})
|
|
1055
|
+
|
|
1056
|
+
// 디바운스/쓰로틀
|
|
1057
|
+
wireview.debounce(300)(fn)
|
|
1058
|
+
wireview.throttle(100)(fn)
|
|
1059
|
+
|
|
1060
|
+
// JS 명령어 실행
|
|
1061
|
+
wireview.exec(element, commands)
|
|
1062
|
+
|
|
1063
|
+
// Hook 정의
|
|
1064
|
+
wireview.hooks.MyHook = {
|
|
1065
|
+
mounted() { /* ... */ },
|
|
1066
|
+
updated() { /* ... */ },
|
|
1067
|
+
destroyed() { /* ... */ }
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// 디버그 유틸리티
|
|
1071
|
+
wireview.debug.enable()
|
|
1072
|
+
wireview.debug.disable()
|
|
1073
|
+
wireview.debug.status()
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
## 컴포넌트 테스트
|
|
1077
|
+
|
|
1078
|
+
WebSocket 없이 컴포넌트 테스트:
|
|
1079
|
+
|
|
1080
|
+
```python
|
|
1081
|
+
import pytest
|
|
1082
|
+
from wireview.testing import mount
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
@pytest.mark.asyncio
|
|
1086
|
+
async def test_counter_increment():
|
|
1087
|
+
view = await mount(Counter, count=0)
|
|
1088
|
+
await view.call("increment", amount=5)
|
|
1089
|
+
assert view.component.count == 5
|
|
1090
|
+
assert len(view.sent_messages) > 0
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
@pytest.mark.asyncio
|
|
1094
|
+
async def test_redirect():
|
|
1095
|
+
view = await mount(MyComponent)
|
|
1096
|
+
await view.call("do_redirect", url="/dashboard")
|
|
1097
|
+
assert view.redirected_to == "/dashboard"
|
|
1098
|
+
assert view.is_frozen
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
### 테스트 API
|
|
1102
|
+
|
|
1103
|
+
| 메서드/속성 | 설명 |
|
|
1104
|
+
|-------------|------|
|
|
1105
|
+
| `mount(ComponentClass, **kwargs)` | 테스트용 컴포넌트 마운트 |
|
|
1106
|
+
| `view.component` | 컴포넌트 인스턴스 접근 |
|
|
1107
|
+
| `view.call(handler, **kwargs)` | 이벤트 핸들러 호출 |
|
|
1108
|
+
| `view.sent_messages` | 전송될 메시지들 |
|
|
1109
|
+
| `view.redirected_to` | 리다이렉트 URL (있는 경우) |
|
|
1110
|
+
| `view.is_frozen` | 컴포넌트 동결 여부 |
|
|
1111
|
+
| `view.clear_messages()` | 전송 메시지 초기화 |
|
|
1112
|
+
|
|
1113
|
+
## 디버그 도구
|
|
1114
|
+
|
|
1115
|
+
```javascript
|
|
1116
|
+
// 디버그 로깅 활성화
|
|
1117
|
+
wireview.debug.enable()
|
|
1118
|
+
|
|
1119
|
+
// 디버그 로깅 비활성화
|
|
1120
|
+
wireview.debug.disable()
|
|
1121
|
+
|
|
1122
|
+
// 네트워크 지연 시뮬레이션
|
|
1123
|
+
wireview.debug.latency(500) // 500ms 지연
|
|
1124
|
+
|
|
1125
|
+
// 연결 상태 표시
|
|
1126
|
+
wireview.debug.status()
|
|
1127
|
+
|
|
1128
|
+
// 모든 컴포넌트 나열
|
|
1129
|
+
wireview.debug.components()
|
|
1130
|
+
|
|
1131
|
+
// 특정 컴포넌트 가져오기
|
|
1132
|
+
wireview.debug.component("rx-123")
|
|
1133
|
+
```
|
|
1134
|
+
|
|
1135
|
+
## 설정
|
|
1136
|
+
|
|
1137
|
+
```python
|
|
1138
|
+
from wireview.schemas import AutoBroadcast
|
|
1139
|
+
|
|
1140
|
+
WIREVIEW = {
|
|
1141
|
+
"TRANSPILER_CACHE_SIZE": 1024, # 이벤트 핸들러 캐시 크기
|
|
1142
|
+
"USE_HTML_DIFF": True, # HTML diff 활성화
|
|
1143
|
+
"USE_HMIN": False, # django-hmin 압축 사용
|
|
1144
|
+
"BOOST_PAGES": False, # 클라이언트 사이드 네비게이션 활성화
|
|
1145
|
+
"AUTO_BROADCAST": AutoBroadcast(
|
|
1146
|
+
model=False, # 모델 변경 시 브로드캐스트
|
|
1147
|
+
model_pk=False, # 채널에 PK 포함
|
|
1148
|
+
related=False, # 관련 모델 변경 브로드캐스트
|
|
1149
|
+
m2m=False, # M2M 변경 브로드캐스트
|
|
1150
|
+
senders=set(), # 자동 브로드캐스트할 모델
|
|
1151
|
+
),
|
|
1152
|
+
}
|
|
1153
|
+
```
|
|
1154
|
+
|
|
1155
|
+
## 성능 최적화
|
|
1156
|
+
|
|
1157
|
+
최적의 성능을 위해:
|
|
1158
|
+
|
|
1159
|
+
- **uvloop 사용**: Uvicorn에서 `--loop uvloop` 옵션으로 더 나은 비동기 성능 달성
|
|
1160
|
+
- **개발 중 전환 추적**: `DEBUG_SYNC_TRANSITIONS=True`로 중첩 async/sync 전환 감지
|
|
1161
|
+
- **컴포넌트에서 `asend_to()` 선호**: async 컨텍스트에서는 `send_to()` 대신 `asend_to()` 사용
|
|
1162
|
+
|
|
1163
|
+
```python
|
|
1164
|
+
WIREVIEW = {
|
|
1165
|
+
"DEBUG_SYNC_TRANSITIONS": True, # 개발 환경에서만
|
|
1166
|
+
"USE_HTML_DIFF": True,
|
|
1167
|
+
"USE_HMIN": True, # django-hmin 설치 필요
|
|
1168
|
+
}
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
자세한 내용은 [성능 가이드](docs/PERFORMANCE.md)를 참조하세요.
|
|
1172
|
+
|
|
1173
|
+
## 문서
|
|
1174
|
+
|
|
1175
|
+
- [아키텍처](docs/ARCHITECTURE.md) - 내부 설계 및 패턴
|
|
1176
|
+
- [배포 가이드](docs/DEPLOYMENT.md) - 프로덕션 배포 설정
|
|
1177
|
+
- [성능 가이드](docs/PERFORMANCE.md) - 성능 최적화 팁
|
|
1178
|
+
- [튜토리얼](docs/tutorials/) - 단계별 가이드
|
|
1179
|
+
- [로드맵](docs/ROADMAP.md) - 향후 개발 계획
|
|
1180
|
+
|
|
1181
|
+
## 개발 및 기여
|
|
1182
|
+
|
|
1183
|
+
```bash
|
|
1184
|
+
git clone git@github.com:itda-work/django-wireview.git
|
|
1185
|
+
cd django-wireview
|
|
1186
|
+
make install
|
|
1187
|
+
make test
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
테스트 서버 실행:
|
|
1191
|
+
|
|
1192
|
+
```bash
|
|
1193
|
+
cd tests
|
|
1194
|
+
python manage.py runserver
|
|
1195
|
+
```
|
|
1196
|
+
|
|
1197
|
+
## 라이선스
|
|
1198
|
+
|
|
1199
|
+
MIT 라이선스 - 자세한 내용은 [LICENSE](LICENSE)를 참조하세요.
|