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.
Files changed (70) hide show
  1. millwork/__init__.py +45 -0
  2. millwork/_bundle/dist/.vite/manifest.json +92 -0
  3. millwork/_bundle/dist/charts-inner-Cq0vWbjt.js +39 -0
  4. millwork/_bundle/dist/favicon.svg +15 -0
  5. millwork/_bundle/dist/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  6. millwork/_bundle/dist/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  7. millwork/_bundle/dist/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  8. millwork/_bundle/dist/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  9. millwork/_bundle/dist/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  10. millwork/_bundle/dist/icons.svg +24 -0
  11. millwork/_bundle/dist/index.global-cOFus1M1.js +952 -0
  12. millwork/_bundle/dist/index.modern-CITEfQrV.js +135 -0
  13. millwork/_bundle/dist/lib-3DI92Zxk.js +1 -0
  14. millwork/_bundle/dist/main-BoZpFGOH.js +64 -0
  15. millwork/_bundle/dist/main-F2tEXmC5.css +2 -0
  16. millwork/_bundle/dist/react-markdown-DLcbNoI3.js +14 -0
  17. millwork/_bundle/dist/remark-gfm-DoHjwpPg.js +16 -0
  18. millwork/app.py +726 -0
  19. millwork/auth.py +877 -0
  20. millwork/chat.py +397 -0
  21. millwork/cli.py +316 -0
  22. millwork/context.py +42 -0
  23. millwork/files.py +450 -0
  24. millwork/forms.py +349 -0
  25. millwork/manual/__init__.py +449 -0
  26. millwork/manual/components/Hello.jsx +24 -0
  27. millwork/manual/components/Voice.jsx +46 -0
  28. millwork/manual/pages/app/auth.md +188 -0
  29. millwork/manual/pages/app/pages.md +200 -0
  30. millwork/manual/pages/app/running.md +242 -0
  31. millwork/manual/pages/app/state.md +151 -0
  32. millwork/manual/pages/app/testing.md +147 -0
  33. millwork/manual/pages/app/theming.md +122 -0
  34. millwork/manual/pages/components/charts.md +263 -0
  35. millwork/manual/pages/components/chat.md +280 -0
  36. millwork/manual/pages/components/custom.md +148 -0
  37. millwork/manual/pages/components/data.md +112 -0
  38. millwork/manual/pages/components/feedback.md +212 -0
  39. millwork/manual/pages/components/files.md +194 -0
  40. millwork/manual/pages/components/form.md +135 -0
  41. millwork/manual/pages/components/inputs.md +360 -0
  42. millwork/manual/pages/components/layout.md +154 -0
  43. millwork/manual/pages/components/tasks.md +140 -0
  44. millwork/manual/pages/components/text.md +145 -0
  45. millwork/manual/pages/index.md +67 -0
  46. millwork/manual/public/fonts/InterVariable.woff2 +0 -0
  47. millwork/manual/public/fonts/LICENSE.txt +2 -0
  48. millwork/manual/public/mark.svg +15 -0
  49. millwork/manual/public/theme.json +97 -0
  50. millwork/nodes.py +300 -0
  51. millwork/protocol.py +238 -0
  52. millwork/proxy.py +122 -0
  53. millwork/py.typed +0 -0
  54. millwork/session.py +1422 -0
  55. millwork/shell.py +226 -0
  56. millwork/tasks.py +473 -0
  57. millwork/testing.py +336 -0
  58. millwork/theme.py +338 -0
  59. millwork/ui/__init__.py +725 -0
  60. millwork/ui/charts.py +214 -0
  61. millwork/ui/chat.py +97 -0
  62. millwork/ui/files.py +101 -0
  63. millwork/ui/form.py +102 -0
  64. millwork/ui/table.py +145 -0
  65. millwork/ws.py +220 -0
  66. millwork-0.1.0.dist-info/METADATA +131 -0
  67. millwork-0.1.0.dist-info/RECORD +70 -0
  68. millwork-0.1.0.dist-info/WHEEL +4 -0
  69. millwork-0.1.0.dist-info/entry_points.txt +2 -0
  70. 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
+ }