millwork 0.1.0__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.
- millwork/__init__.py +45 -0
- millwork/_bundle/dist/.vite/manifest.json +92 -0
- millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
- millwork/_bundle/dist/favicon.svg +15 -0
- millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- millwork/_bundle/dist/icons.svg +24 -0
- millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
- millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
- millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
- millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
- millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
- millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
- millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
- millwork/app.py +726 -0
- millwork/auth.py +877 -0
- millwork/chat.py +397 -0
- millwork/cli.py +316 -0
- millwork/context.py +42 -0
- millwork/files.py +450 -0
- millwork/forms.py +349 -0
- millwork/manual/__init__.py +449 -0
- millwork/manual/components/Hello.jsx +24 -0
- millwork/manual/components/Voice.jsx +46 -0
- millwork/manual/pages/app/auth.md +188 -0
- millwork/manual/pages/app/pages.md +200 -0
- millwork/manual/pages/app/running.md +242 -0
- millwork/manual/pages/app/state.md +151 -0
- millwork/manual/pages/app/testing.md +147 -0
- millwork/manual/pages/app/theming.md +122 -0
- millwork/manual/pages/components/charts.md +263 -0
- millwork/manual/pages/components/chat.md +280 -0
- millwork/manual/pages/components/custom.md +148 -0
- millwork/manual/pages/components/data.md +112 -0
- millwork/manual/pages/components/feedback.md +212 -0
- millwork/manual/pages/components/files.md +194 -0
- millwork/manual/pages/components/form.md +135 -0
- millwork/manual/pages/components/inputs.md +360 -0
- millwork/manual/pages/components/layout.md +154 -0
- millwork/manual/pages/components/tasks.md +140 -0
- millwork/manual/pages/components/text.md +145 -0
- millwork/manual/pages/index.md +67 -0
- millwork/manual/public/fonts/InterVariable.woff2 +0 -0
- millwork/manual/public/fonts/LICENSE.txt +2 -0
- millwork/manual/public/mark.svg +15 -0
- millwork/manual/public/theme.json +97 -0
- millwork/nodes.py +300 -0
- millwork/protocol.py +238 -0
- millwork/proxy.py +122 -0
- millwork/py.typed +0 -0
- millwork/session.py +1422 -0
- millwork/shell.py +226 -0
- millwork/tasks.py +473 -0
- millwork/testing.py +336 -0
- millwork/theme.py +338 -0
- millwork/ui/__init__.py +725 -0
- millwork/ui/charts.py +214 -0
- millwork/ui/chat.py +97 -0
- millwork/ui/files.py +101 -0
- millwork/ui/form.py +102 -0
- millwork/ui/table.py +145 -0
- millwork/ws.py +220 -0
- millwork-0.1.0.dist-info/METADATA +131 -0
- millwork-0.1.0.dist-info/RECORD +70 -0
- millwork-0.1.0.dist-info/WHEEL +4 -0
- millwork-0.1.0.dist-info/entry_points.txt +2 -0
- millwork-0.1.0.dist-info/licenses/LICENSE +202 -0
millwork/ui/charts.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Charts: rows in, a drawn chart out, coloured from the theme's chart tokens.
|
|
2
|
+
|
|
3
|
+
A chart takes rows like the Table does. ``x`` names the category or time
|
|
4
|
+
column; ``y`` names one value column or several (wide rows), or one value
|
|
5
|
+
column with ``by`` naming the column that holds the series name (long rows),
|
|
6
|
+
which Python pivots to wide before the wire. Series keep the order they are
|
|
7
|
+
given or first appear in and take ``chart-1`` to ``chart-5`` in that order,
|
|
8
|
+
so a series keeps its colour when a filter removes a neighbour.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping, Sequence
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from typing import Any, ClassVar, Literal
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, PrivateAttr, model_validator
|
|
18
|
+
from pydantic_core import to_jsonable_python
|
|
19
|
+
|
|
20
|
+
from millwork.nodes import Component
|
|
21
|
+
|
|
22
|
+
MAX_SERIES = 5
|
|
23
|
+
"""Five hues the theme defines; a sixth series would need a generated colour."""
|
|
24
|
+
|
|
25
|
+
ValueFormat = Literal["number", "integer", "percent", "currency"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _value(v: Any) -> Any:
|
|
29
|
+
# A Decimal (database rows) would become a string, and a string is a gap.
|
|
30
|
+
if isinstance(v, Decimal):
|
|
31
|
+
return float(v)
|
|
32
|
+
return to_jsonable_python(v)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _row_dict(row: Any) -> dict[str, Any]:
|
|
36
|
+
if isinstance(row, BaseModel):
|
|
37
|
+
return {k: _value(v) for k, v in row.model_dump().items()}
|
|
38
|
+
if isinstance(row, Mapping):
|
|
39
|
+
return {str(k): _value(v) for k, v in row.items()}
|
|
40
|
+
raise TypeError(f"a chart row is a mapping or a model, not {type(row).__name__}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _series_spec(y: Any) -> list[dict[str, str]]:
|
|
44
|
+
if isinstance(y, str):
|
|
45
|
+
return [{"key": y, "label": y}]
|
|
46
|
+
if isinstance(y, Mapping):
|
|
47
|
+
return [{"key": str(k), "label": str(v)} for k, v in y.items()]
|
|
48
|
+
if isinstance(y, Sequence):
|
|
49
|
+
out: list[dict[str, str]] = []
|
|
50
|
+
for item in y:
|
|
51
|
+
if isinstance(item, str):
|
|
52
|
+
out.append({"key": item, "label": item})
|
|
53
|
+
elif isinstance(item, Sequence) and len(item) == 2:
|
|
54
|
+
out.append({"key": str(item[0]), "label": str(item[1])})
|
|
55
|
+
else:
|
|
56
|
+
raise TypeError("a series is a column name or a (column, label) pair")
|
|
57
|
+
return out
|
|
58
|
+
raise TypeError("y is a column name, a list of them, or a mapping of column to label")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _Chart(Component):
|
|
62
|
+
"""What every chart shares: rows, the x column, series, formats, a click."""
|
|
63
|
+
|
|
64
|
+
wire_exclude: ClassVar[frozenset[str]] = frozenset({"rows", "y", "by"})
|
|
65
|
+
|
|
66
|
+
rows: Sequence[Any]
|
|
67
|
+
x: str
|
|
68
|
+
y: Any
|
|
69
|
+
by: str | None = None
|
|
70
|
+
format: ValueFormat | None = None
|
|
71
|
+
labels: bool = False
|
|
72
|
+
legend: bool | None = None
|
|
73
|
+
emphasis: str | None = None
|
|
74
|
+
height: int | None = None
|
|
75
|
+
on_click: Any = None
|
|
76
|
+
|
|
77
|
+
# Built once from the rows: the series in drawing order and the wide points.
|
|
78
|
+
_series: list[dict[str, str]] = PrivateAttr()
|
|
79
|
+
_points: list[dict[str, Any]] = PrivateAttr()
|
|
80
|
+
|
|
81
|
+
def __init__(self, rows: Sequence[Any] = (), **props: Any) -> None:
|
|
82
|
+
super().__init__(rows=rows, **props)
|
|
83
|
+
|
|
84
|
+
@model_validator(mode="after")
|
|
85
|
+
def _check(self) -> _Chart:
|
|
86
|
+
if self.by is not None and not isinstance(self.y, str):
|
|
87
|
+
raise ValueError("with by=, y names the one column that holds the values")
|
|
88
|
+
dicts = [_row_dict(r) for r in self.rows]
|
|
89
|
+
if dicts:
|
|
90
|
+
wanted = [self.x, self.by, str(self.y)] if self.by else [self.x]
|
|
91
|
+
missing = [c for c in wanted if c not in dicts[0]]
|
|
92
|
+
if missing:
|
|
93
|
+
raise ValueError(f"the rows have no column {missing[0]!r}")
|
|
94
|
+
series = self._build_series(dicts)
|
|
95
|
+
if not series:
|
|
96
|
+
raise ValueError("a chart needs at least one series")
|
|
97
|
+
if len(series) > MAX_SERIES:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"a chart draws {MAX_SERIES} series at most; fold the rest into one, "
|
|
100
|
+
"or draw several charts"
|
|
101
|
+
)
|
|
102
|
+
keys = [s["key"] for s in series]
|
|
103
|
+
if len(set(keys)) != len(keys):
|
|
104
|
+
raise ValueError("series keys repeat")
|
|
105
|
+
if "x" in keys:
|
|
106
|
+
raise ValueError("a series cannot be called 'x'; that name is the category")
|
|
107
|
+
if self.emphasis is not None and self.emphasis not in keys:
|
|
108
|
+
raise ValueError(f"emphasis names a series; {self.emphasis!r} is not one of {keys}")
|
|
109
|
+
if dicts and self.by is None:
|
|
110
|
+
missing = [k for k in keys if k not in dicts[0]]
|
|
111
|
+
if missing:
|
|
112
|
+
hint = ""
|
|
113
|
+
if isinstance(self.y, tuple) and len(self.y) == 2:
|
|
114
|
+
hint = " (a (column, label) pair goes inside a list)"
|
|
115
|
+
raise ValueError(f"the rows have no column {missing[0]!r}{hint}")
|
|
116
|
+
self._series = series
|
|
117
|
+
self._points = self._build_points(dicts, keys)
|
|
118
|
+
return self
|
|
119
|
+
|
|
120
|
+
def _build_series(self, dicts: list[dict[str, Any]]) -> list[dict[str, str]]:
|
|
121
|
+
if self.by is None:
|
|
122
|
+
return _series_spec(self.y)
|
|
123
|
+
value = str(self.y)
|
|
124
|
+
seen: dict[str, None] = {}
|
|
125
|
+
for r in dicts:
|
|
126
|
+
name = r.get(self.by)
|
|
127
|
+
if name is not None:
|
|
128
|
+
seen.setdefault(str(name), None)
|
|
129
|
+
return [{"key": k, "label": k} for k in seen] or [{"key": value, "label": value}]
|
|
130
|
+
|
|
131
|
+
def _build_points(self, dicts: list[dict[str, Any]], keys: list[str]) -> list[dict[str, Any]]:
|
|
132
|
+
if self.by is None:
|
|
133
|
+
return [{"x": r.get(self.x), **{k: r.get(k) for k in keys}} for r in dicts]
|
|
134
|
+
value = str(self.y)
|
|
135
|
+
order: dict[Any, dict[str, Any]] = {}
|
|
136
|
+
for r in dicts:
|
|
137
|
+
x = r.get(self.x)
|
|
138
|
+
try:
|
|
139
|
+
point = order.setdefault(x, {"x": x})
|
|
140
|
+
except TypeError:
|
|
141
|
+
raise ValueError(f"with by=, x values must be hashable, not {x!r}") from None
|
|
142
|
+
name = r.get(self.by)
|
|
143
|
+
if name is None:
|
|
144
|
+
continue
|
|
145
|
+
if str(name) in point:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"two rows have {self.x}={x!r} and {self.by}={name!r}; group the rows first"
|
|
148
|
+
)
|
|
149
|
+
point[str(name)] = r.get(value)
|
|
150
|
+
return [{"x": p["x"], **{k: p.get(k) for k in keys}} for p in order.values()]
|
|
151
|
+
|
|
152
|
+
def series(self) -> list[dict[str, str]]:
|
|
153
|
+
"""The series on the wire: ``{key, label}`` in drawing order."""
|
|
154
|
+
return self._series
|
|
155
|
+
|
|
156
|
+
def points(self) -> list[dict[str, Any]]:
|
|
157
|
+
"""Wide rows: ``x`` plus one value per series key, in row order."""
|
|
158
|
+
return self._points
|
|
159
|
+
|
|
160
|
+
def wire_extra(self) -> dict[str, Any]:
|
|
161
|
+
return {"data": self._points, "series": self._series}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class BarChart(_Chart):
|
|
165
|
+
"""Bars for magnitudes by category: grouped, ``stacked``, ``horizontal``.
|
|
166
|
+
|
|
167
|
+
``stacked="percent"`` stacks to a full bar. ``diverging`` is for an
|
|
168
|
+
ordered scale such as agree to disagree: the series are a scale from
|
|
169
|
+
one pole to the other, drawn to either side of a neutral midpoint, and
|
|
170
|
+
coloured from two hues through grey instead of five. Its values are
|
|
171
|
+
counts, never negative, and it draws no value labels.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
type: ClassVar[str] = "BarChart"
|
|
175
|
+
stacked: bool | Literal["percent"] = False
|
|
176
|
+
horizontal: bool = False
|
|
177
|
+
diverging: bool = False
|
|
178
|
+
|
|
179
|
+
@model_validator(mode="after")
|
|
180
|
+
def _check_bars(self) -> BarChart:
|
|
181
|
+
if self.diverging and self.stacked == "percent":
|
|
182
|
+
raise ValueError("a diverging chart is stacked around its midpoint; percent is not")
|
|
183
|
+
if self.diverging and self.emphasis is not None:
|
|
184
|
+
raise ValueError("a diverging chart colours a scale, not one series")
|
|
185
|
+
if self.diverging:
|
|
186
|
+
for point in self.points():
|
|
187
|
+
for k, v in point.items():
|
|
188
|
+
if k != "x" and isinstance(v, int | float) and v < 0:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"a diverging chart stacks counts to either side; {k}={v!r} is negative"
|
|
191
|
+
)
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class LineChart(_Chart):
|
|
196
|
+
"""Lines over an ordered x: one per series, a crosshair tooltip across them.
|
|
197
|
+
|
|
198
|
+
``curve`` smooths the lines; ``dots`` marks every point.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
type: ClassVar[str] = "LineChart"
|
|
202
|
+
curve: bool = False
|
|
203
|
+
dots: bool = False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class AreaChart(_Chart):
|
|
207
|
+
"""Lines with the space under them filled; ``stacked`` adds the series up."""
|
|
208
|
+
|
|
209
|
+
type: ClassVar[str] = "AreaChart"
|
|
210
|
+
stacked: bool | Literal["percent"] = False
|
|
211
|
+
curve: bool = False
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
__all__ = ["AreaChart", "BarChart", "LineChart", "MAX_SERIES"]
|
millwork/ui/chat.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""The Chat component: one handler, one reply handle, messages the session owns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, ClassVar, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict
|
|
8
|
+
|
|
9
|
+
from millwork import context
|
|
10
|
+
from millwork.nodes import Component
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Command(BaseModel):
|
|
14
|
+
model_config = ConfigDict(extra="forbid")
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
label: str
|
|
18
|
+
icon: str | None = None
|
|
19
|
+
description: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Action(BaseModel):
|
|
23
|
+
"""A button under an assistant message; ``on_action`` receives its id."""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(extra="forbid")
|
|
26
|
+
|
|
27
|
+
id: str
|
|
28
|
+
label: str
|
|
29
|
+
icon: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Accept(BaseModel):
|
|
33
|
+
"""What the composer takes: the extensions, how many per message, how large.
|
|
34
|
+
``capture`` opens the camera or the microphone straight away on a phone;
|
|
35
|
+
``paperclip=False`` hides the attach button while uploads from the chat's
|
|
36
|
+
``tools`` still go by these limits."""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(extra="forbid")
|
|
39
|
+
|
|
40
|
+
accept: list[str] | None = None
|
|
41
|
+
max_files: int = 5
|
|
42
|
+
max_size_mb: int | None = None
|
|
43
|
+
capture: Literal["user", "environment"] | None = None
|
|
44
|
+
paperclip: bool = True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Chat(Component):
|
|
48
|
+
type: ClassVar[str] = "Chat"
|
|
49
|
+
wire_exclude: ClassVar[frozenset[str]] = frozenset()
|
|
50
|
+
|
|
51
|
+
welcome: str | None = None
|
|
52
|
+
suggestions: list[str] = []
|
|
53
|
+
placeholder: str | None = None
|
|
54
|
+
attachments: Accept | None = None
|
|
55
|
+
# The longest message and the most of them the session keeps for the
|
|
56
|
+
# thread, the oldest going first: together they bound what a thread can
|
|
57
|
+
# hold, whatever is sent.
|
|
58
|
+
max_chars: int = 16_000
|
|
59
|
+
max_messages: int = 200
|
|
60
|
+
commands: list[Command] = []
|
|
61
|
+
disabled: bool = False
|
|
62
|
+
show_steps: Literal["full", "collapsed", "hidden"] = "collapsed"
|
|
63
|
+
# Reading aids, each off by default: the assistant's picture beside its
|
|
64
|
+
# messages, a name and a time above each message (on hover, always without
|
|
65
|
+
# a mouse), and how long a reply and each of its steps took.
|
|
66
|
+
avatar: str | None = None
|
|
67
|
+
assistant_name: str | None = None
|
|
68
|
+
names: bool = False
|
|
69
|
+
timestamps: bool = False
|
|
70
|
+
durations: bool = False
|
|
71
|
+
# Under an assistant message: copy, thumbs up and down (with ``on_feedback``),
|
|
72
|
+
# regenerate on the last one, the app's own ``actions``; a user message can
|
|
73
|
+
# be edited and resent. Each is off without its handler or flag.
|
|
74
|
+
copyable: bool = True
|
|
75
|
+
regenerate: bool = True
|
|
76
|
+
edit: bool = True
|
|
77
|
+
actions: list[Action] = []
|
|
78
|
+
# Components in the composer, left of Send: a Button with a handler, or a
|
|
79
|
+
# Custom component, which receives ``chat`` with upload, send and busy.
|
|
80
|
+
tools: list[Component] = []
|
|
81
|
+
on_message: Any = None
|
|
82
|
+
on_stop: Any = None
|
|
83
|
+
on_feedback: Any = None
|
|
84
|
+
on_action: Any = None
|
|
85
|
+
|
|
86
|
+
def wire_extra(self) -> dict[str, Any]:
|
|
87
|
+
"""The finished messages and the thread's live bits; the in-flight one is the stream's."""
|
|
88
|
+
s = context.current()
|
|
89
|
+
if s is None or self.key is None:
|
|
90
|
+
return {"messages": [], "busy": False, "inline": {}}
|
|
91
|
+
thread = s.chat(self.key)
|
|
92
|
+
return {
|
|
93
|
+
"messages": [m.model_dump(mode="json") for m in thread.messages],
|
|
94
|
+
"busy": thread.busy,
|
|
95
|
+
"live_suggestions": thread.suggestions,
|
|
96
|
+
"inline": dict(thread.inline),
|
|
97
|
+
}
|
millwork/ui/files.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Files on a page: upload, cards, viewers, downloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, ClassVar, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import field_validator
|
|
8
|
+
|
|
9
|
+
from millwork.files import FileRef
|
|
10
|
+
from millwork.nodes import Component
|
|
11
|
+
from millwork.ui.table import MenuItem
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _safe_src(value: Any) -> Any:
|
|
15
|
+
if isinstance(value, str):
|
|
16
|
+
from millwork.ui import safe_href
|
|
17
|
+
|
|
18
|
+
return safe_href(value)
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FileUpload(Component):
|
|
23
|
+
"""Uploads go over the session's socket; the handler receives ``FileRef``s."""
|
|
24
|
+
|
|
25
|
+
type: ClassVar[str] = "FileUpload"
|
|
26
|
+
accept: list[str] | None = None
|
|
27
|
+
multiple: bool = False
|
|
28
|
+
max_files: int = 10
|
|
29
|
+
max_size_mb: int | None = None
|
|
30
|
+
label: str | None = None
|
|
31
|
+
description: str | None = None
|
|
32
|
+
dropzone: bool = True
|
|
33
|
+
disabled: bool = False
|
|
34
|
+
on_upload: Any = None
|
|
35
|
+
on_reject: Any = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FileCard(Component):
|
|
39
|
+
type: ClassVar[str] = "FileCard"
|
|
40
|
+
file: FileRef | None = None
|
|
41
|
+
name: str | None = None
|
|
42
|
+
size: int | None = None
|
|
43
|
+
kind: str | None = None
|
|
44
|
+
url: str | None = None
|
|
45
|
+
status: Literal["ready", "loading", "error"] = "ready"
|
|
46
|
+
_url = field_validator("url")(staticmethod(_safe_src))
|
|
47
|
+
meta: str | None = None
|
|
48
|
+
actions: list[MenuItem] = []
|
|
49
|
+
on_action: Any = None
|
|
50
|
+
on_click: Any = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Image(Component):
|
|
54
|
+
type: ClassVar[str] = "Image"
|
|
55
|
+
src: FileRef | str
|
|
56
|
+
alt: str = ""
|
|
57
|
+
_src = field_validator("src")(staticmethod(_safe_src))
|
|
58
|
+
size: Literal["sm", "md", "lg", "full"] = "md"
|
|
59
|
+
fit: Literal["cover", "contain"] = "contain"
|
|
60
|
+
on_click: Any = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Pdf(Component):
|
|
64
|
+
"""A PDF in the browser's own viewer. A form or a link inside the document
|
|
65
|
+
looks like part of the app, so show files the app made or the person
|
|
66
|
+
uploaded themselves, not documents from strangers."""
|
|
67
|
+
|
|
68
|
+
type: ClassVar[str] = "Pdf"
|
|
69
|
+
src: FileRef | str
|
|
70
|
+
page: int | None = None
|
|
71
|
+
_src = field_validator("src")(staticmethod(_safe_src))
|
|
72
|
+
height: str = "70vh"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Download(Component):
|
|
76
|
+
"""A button that downloads a file, bytes, text, or what a callable returns on click."""
|
|
77
|
+
|
|
78
|
+
type: ClassVar[str] = "Download"
|
|
79
|
+
wire_exclude: ClassVar[frozenset[str]] = frozenset({"content"})
|
|
80
|
+
|
|
81
|
+
label: str = "Download"
|
|
82
|
+
filename: str
|
|
83
|
+
content: Any
|
|
84
|
+
mime: str | None = None
|
|
85
|
+
variant: Literal["primary", "secondary", "outline", "ghost", "link"] = "outline"
|
|
86
|
+
icon: str | None = "download"
|
|
87
|
+
disabled: bool = False
|
|
88
|
+
|
|
89
|
+
def __init__(self, label: str = "Download", **props: Any) -> None:
|
|
90
|
+
super().__init__(label=label, **props)
|
|
91
|
+
|
|
92
|
+
def wire_extra(self) -> dict[str, Any]:
|
|
93
|
+
# A session file downloads by id, straight over the socket: no click round
|
|
94
|
+
# trip, no copy of the file.
|
|
95
|
+
if isinstance(self.content, FileRef):
|
|
96
|
+
from millwork import context
|
|
97
|
+
|
|
98
|
+
s = context.current()
|
|
99
|
+
if s is not None and s.id == self.content.session:
|
|
100
|
+
return {"file_id": self.content.id}
|
|
101
|
+
return {}
|
millwork/ui/form.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""The Form component: a Pydantic model becomes fields, a submit becomes an instance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any, ClassVar, Literal, Type # noqa: UP035
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from millwork.forms import Issue, field_spec
|
|
11
|
+
from millwork.nodes import Component
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Form(Component):
|
|
15
|
+
type: ClassVar[str] = "Form"
|
|
16
|
+
wire_exclude: ClassVar[frozenset[str]] = frozenset({"model", "initial"})
|
|
17
|
+
|
|
18
|
+
# ``Type`` rather than ``type``: the class body's ``type`` is the component's wire name.
|
|
19
|
+
model: Type[BaseModel] # noqa: UP006
|
|
20
|
+
# Any: a ``BaseModel`` annotation would make pydantic try to build one from a dict.
|
|
21
|
+
initial: Any = None
|
|
22
|
+
submit: str | None = "Save"
|
|
23
|
+
cancel: str | None = None
|
|
24
|
+
layout: Literal["vertical", "horizontal"] | int = "vertical"
|
|
25
|
+
fields: list[str] | None = None
|
|
26
|
+
labels: Mapping[str, Mapping[str, Any]] | None = None
|
|
27
|
+
# pointer (or field name) to spec keys: widget, choices, placeholder, min, max, step, rows...
|
|
28
|
+
overrides: Mapping[str, Mapping[str, Any]] | None = None
|
|
29
|
+
issues: list[Issue] = []
|
|
30
|
+
disabled: bool = False
|
|
31
|
+
read_only: bool = False
|
|
32
|
+
dense: bool = False
|
|
33
|
+
on_submit: Any = None
|
|
34
|
+
on_cancel: Any = None
|
|
35
|
+
|
|
36
|
+
def __init__(self, model: Any = None, **props: Any) -> None:
|
|
37
|
+
if model is not None:
|
|
38
|
+
props["model"] = model
|
|
39
|
+
super().__init__(**props)
|
|
40
|
+
|
|
41
|
+
def wire_extra(self) -> dict[str, Any]:
|
|
42
|
+
spec = field_spec(self.model)
|
|
43
|
+
if self.fields is not None:
|
|
44
|
+
order = {name: i for i, name in enumerate(self.fields)}
|
|
45
|
+
spec["fields"] = sorted(
|
|
46
|
+
(f for f in spec["fields"] if f["name"] in order), key=lambda f: order[f["name"]]
|
|
47
|
+
)
|
|
48
|
+
if self.labels:
|
|
49
|
+
_apply_labels(spec["fields"], self.labels)
|
|
50
|
+
if self.overrides:
|
|
51
|
+
_apply_overrides(spec["fields"], self.overrides)
|
|
52
|
+
initial: Any = None
|
|
53
|
+
if isinstance(self.initial, BaseModel):
|
|
54
|
+
initial = self.initial.model_dump(mode="json")
|
|
55
|
+
elif isinstance(self.initial, Mapping):
|
|
56
|
+
initial = dict(self.initial)
|
|
57
|
+
elif self.initial is not None:
|
|
58
|
+
raise TypeError("Form.initial is a model instance or a mapping")
|
|
59
|
+
# The key doubles as the form element's id, so a Button(type="submit", form=key)
|
|
60
|
+
# anywhere on the page submits it.
|
|
61
|
+
return {"schema": spec, "initial": initial, "form_id": self.key}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _apply_labels(fields: list[dict[str, Any]], labels: Mapping[str, Mapping[str, Any]]) -> None:
|
|
65
|
+
_apply(fields, labels, keys=("label", "help", "placeholder"))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _apply_overrides(
|
|
69
|
+
fields: list[dict[str, Any]], overrides: Mapping[str, Mapping[str, Any]]
|
|
70
|
+
) -> None:
|
|
71
|
+
_apply(fields, overrides, keys=None)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _apply(
|
|
75
|
+
fields: list[dict[str, Any]],
|
|
76
|
+
table: Mapping[str, Mapping[str, Any]],
|
|
77
|
+
keys: tuple[str, ...] | None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Merge ``table[pointer]`` (or ``table[name]``) into each field spec, recursively.
|
|
80
|
+
|
|
81
|
+
``keys`` limits what is taken; ``None`` takes everything. ``choices`` becomes
|
|
82
|
+
``options`` either way.
|
|
83
|
+
"""
|
|
84
|
+
for f in fields:
|
|
85
|
+
override = table.get(f["pointer"]) or table.get(f["name"])
|
|
86
|
+
if override:
|
|
87
|
+
for k, v in override.items():
|
|
88
|
+
if k == "choices":
|
|
89
|
+
f["options"] = [
|
|
90
|
+
{"value": c[0], "label": c[1]} if isinstance(c, tuple | list) else c
|
|
91
|
+
for c in v
|
|
92
|
+
]
|
|
93
|
+
if f["widget"] == "input":
|
|
94
|
+
f["widget"] = "select"
|
|
95
|
+
elif keys is None or k in keys:
|
|
96
|
+
f[k] = v
|
|
97
|
+
if "fields" in f:
|
|
98
|
+
_apply(f["fields"], table, keys)
|
|
99
|
+
if "item" in f and "fields" in f["item"]:
|
|
100
|
+
_apply(f["item"]["fields"], table, keys)
|
|
101
|
+
for v in f.get("variants", []):
|
|
102
|
+
_apply(v["fields"], table, keys)
|
millwork/ui/table.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The Table: rows as data, columns as a spec, tones instead of styles.
|
|
2
|
+
|
|
3
|
+
Phase 1: every row is in the browser;
|
|
4
|
+
sort, filter and pagination are the table's business; selection is the
|
|
5
|
+
server's.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
11
|
+
from typing import Any, ClassVar, Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, field_validator
|
|
14
|
+
from pydantic_core import to_jsonable_python
|
|
15
|
+
|
|
16
|
+
from millwork.nodes import Component
|
|
17
|
+
|
|
18
|
+
Format = Literal[
|
|
19
|
+
"text",
|
|
20
|
+
"number",
|
|
21
|
+
"integer",
|
|
22
|
+
"percent",
|
|
23
|
+
"currency",
|
|
24
|
+
"date",
|
|
25
|
+
"datetime",
|
|
26
|
+
"bool",
|
|
27
|
+
"badge",
|
|
28
|
+
"link",
|
|
29
|
+
"code",
|
|
30
|
+
]
|
|
31
|
+
Tone = Literal["default", "muted", "primary", "accent", "success", "warning", "destructive"]
|
|
32
|
+
Row = Mapping[str, Any]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Column(BaseModel):
|
|
36
|
+
model_config = ConfigDict(extra="forbid")
|
|
37
|
+
|
|
38
|
+
key: str
|
|
39
|
+
header: str | None = None
|
|
40
|
+
width: int | str | None = None
|
|
41
|
+
align: Literal["left", "center", "right"] = "left"
|
|
42
|
+
format: Format = "text"
|
|
43
|
+
sortable: bool = True
|
|
44
|
+
filterable: bool = True
|
|
45
|
+
hidden: bool = False
|
|
46
|
+
wrap: bool = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MenuItem(BaseModel):
|
|
50
|
+
model_config = ConfigDict(extra="forbid")
|
|
51
|
+
|
|
52
|
+
id: str
|
|
53
|
+
label: str
|
|
54
|
+
icon: str | None = None
|
|
55
|
+
disabled: bool = False
|
|
56
|
+
destructive: bool = False
|
|
57
|
+
separator_after: bool = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _row_dict(row: Any) -> dict[str, Any]:
|
|
61
|
+
if isinstance(row, BaseModel):
|
|
62
|
+
return row.model_dump(mode="json")
|
|
63
|
+
if isinstance(row, Mapping):
|
|
64
|
+
return {str(k): to_jsonable_python(v) for k, v in row.items()}
|
|
65
|
+
raise TypeError(f"a table row is a mapping or a model, not {type(row).__name__}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Table(Component):
|
|
69
|
+
type: ClassVar[str] = "Table"
|
|
70
|
+
wire_exclude: ClassVar[frozenset[str]] = frozenset({"tones", "rows"})
|
|
71
|
+
|
|
72
|
+
rows: Sequence[Any] = []
|
|
73
|
+
columns: list[Column] | None = None
|
|
74
|
+
row_key: str = "id"
|
|
75
|
+
height: int | str | None = None
|
|
76
|
+
sticky_header: bool = True
|
|
77
|
+
sortable: bool = True
|
|
78
|
+
default_sort: list[tuple[str, Literal["asc", "desc"]]] = []
|
|
79
|
+
filters: bool = False
|
|
80
|
+
search: bool = False
|
|
81
|
+
page_size: int | None = None
|
|
82
|
+
selection: Literal["none", "row", "rows"] = "none"
|
|
83
|
+
selected: list[Any] = []
|
|
84
|
+
row_actions: list[MenuItem] = []
|
|
85
|
+
tones: Callable[[Any], str | None] | Mapping[str, Callable[[Any, Any], str | None]] | None = (
|
|
86
|
+
None
|
|
87
|
+
)
|
|
88
|
+
summary: bool | str = True
|
|
89
|
+
empty: str = ""
|
|
90
|
+
on_select: Any = None
|
|
91
|
+
on_row_click: Any = None
|
|
92
|
+
on_action: Any = None
|
|
93
|
+
on_sort: Any = None
|
|
94
|
+
on_filter: Any = None
|
|
95
|
+
|
|
96
|
+
@field_validator("columns", mode="before")
|
|
97
|
+
@classmethod
|
|
98
|
+
def _columns(cls, value: Any) -> Any:
|
|
99
|
+
if value is None:
|
|
100
|
+
return None
|
|
101
|
+
out = []
|
|
102
|
+
for c in value:
|
|
103
|
+
if isinstance(c, str):
|
|
104
|
+
out.append(Column(key=c))
|
|
105
|
+
elif isinstance(c, Column):
|
|
106
|
+
out.append(c)
|
|
107
|
+
elif isinstance(c, Mapping):
|
|
108
|
+
out.append(Column(**c))
|
|
109
|
+
else:
|
|
110
|
+
raise TypeError(f"not a column: {c!r}")
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
def wire_extra(self) -> dict[str, Any]:
|
|
114
|
+
rows = [_row_dict(r) for r in self.rows]
|
|
115
|
+
columns = self.columns
|
|
116
|
+
if columns is None:
|
|
117
|
+
keys = list(rows[0].keys()) if rows else []
|
|
118
|
+
columns = [Column(key=k) for k in keys]
|
|
119
|
+
tone_rows: dict[int, str] = {}
|
|
120
|
+
tone_cells: dict[int, dict[str, str]] = {}
|
|
121
|
+
if callable(self.tones):
|
|
122
|
+
for i, raw in enumerate(self.rows):
|
|
123
|
+
tone = self.tones(raw)
|
|
124
|
+
if tone:
|
|
125
|
+
tone_rows[i] = tone
|
|
126
|
+
elif self.tones:
|
|
127
|
+
for i, (raw, row) in enumerate(zip(self.rows, rows, strict=True)):
|
|
128
|
+
for col, fn in self.tones.items():
|
|
129
|
+
tone = fn(raw, row.get(col))
|
|
130
|
+
if tone:
|
|
131
|
+
tone_cells.setdefault(i, {})[col] = tone
|
|
132
|
+
# Keys the browser uses for selection and clicks. Rows without the key column
|
|
133
|
+
# fall back to their index, which only works when no row has a key to clash with.
|
|
134
|
+
keyed = [self.row_key in row for row in rows]
|
|
135
|
+
if any(keyed) and not all(keyed):
|
|
136
|
+
missing = keyed.index(False)
|
|
137
|
+
raise ValueError(f"table row {missing} has no {self.row_key!r}; every row needs one")
|
|
138
|
+
keys = [to_jsonable_python(row.get(self.row_key, i)) for i, row in enumerate(rows)]
|
|
139
|
+
return {
|
|
140
|
+
"rows": rows,
|
|
141
|
+
"keys": keys,
|
|
142
|
+
"columns": [c.model_dump(mode="json") for c in columns],
|
|
143
|
+
"tone_rows": {str(i): t for i, t in tone_rows.items()},
|
|
144
|
+
"tone_cells": {str(i): t for i, t in tone_cells.items()},
|
|
145
|
+
}
|