@qingflow-tech/qingflow-app-builder-mcp 1.0.2 → 1.0.4

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 (53) hide show
  1. package/README.md +2 -2
  2. package/docs/local-agent-install.md +9 -3
  3. package/npm/lib/runtime.mjs +10 -3
  4. package/package.json +1 -1
  5. package/pyproject.toml +1 -1
  6. package/skills/qingflow-app-builder/SKILL.md +88 -184
  7. package/skills/qingflow-app-builder/references/create-app.md +15 -34
  8. package/skills/qingflow-app-builder/references/gotchas.md +3 -3
  9. package/skills/qingflow-app-builder/references/solution-playbooks.md +1 -2
  10. package/skills/qingflow-app-builder/references/tool-selection.md +9 -10
  11. package/src/qingflow_mcp/__init__.py +33 -1
  12. package/src/qingflow_mcp/backend_client.py +109 -0
  13. package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +58 -9
  15. package/src/qingflow_mcp/builder_facade/service.py +1711 -240
  16. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  17. package/src/qingflow_mcp/cli/commands/app.py +47 -1
  18. package/src/qingflow_mcp/cli/commands/auth.py +63 -0
  19. package/src/qingflow_mcp/cli/commands/builder.py +11 -3
  20. package/src/qingflow_mcp/cli/commands/exports.py +111 -0
  21. package/src/qingflow_mcp/cli/commands/record.py +5 -5
  22. package/src/qingflow_mcp/cli/commands/task.py +701 -27
  23. package/src/qingflow_mcp/cli/commands/workspace.py +84 -0
  24. package/src/qingflow_mcp/cli/context.py +3 -0
  25. package/src/qingflow_mcp/cli/formatters.py +424 -50
  26. package/src/qingflow_mcp/cli/interaction.py +72 -0
  27. package/src/qingflow_mcp/cli/main.py +11 -1
  28. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  29. package/src/qingflow_mcp/cli/terminal_ui.py +218 -0
  30. package/src/qingflow_mcp/config.py +1 -1
  31. package/src/qingflow_mcp/errors.py +4 -4
  32. package/src/qingflow_mcp/export_store.py +14 -0
  33. package/src/qingflow_mcp/id_utils.py +49 -0
  34. package/src/qingflow_mcp/public_surface.py +16 -1
  35. package/src/qingflow_mcp/response_trim.py +394 -9
  36. package/src/qingflow_mcp/server.py +26 -0
  37. package/src/qingflow_mcp/server_app_builder.py +15 -1
  38. package/src/qingflow_mcp/server_app_user.py +113 -0
  39. package/src/qingflow_mcp/session_store.py +126 -21
  40. package/src/qingflow_mcp/solution/compiler/form_compiler.py +2 -2
  41. package/src/qingflow_mcp/solution/executor.py +2 -2
  42. package/src/qingflow_mcp/tools/ai_builder_tools.py +107 -34
  43. package/src/qingflow_mcp/tools/app_tools.py +1 -0
  44. package/src/qingflow_mcp/tools/auth_tools.py +243 -9
  45. package/src/qingflow_mcp/tools/base.py +6 -2
  46. package/src/qingflow_mcp/tools/code_block_tools.py +2 -2
  47. package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
  48. package/src/qingflow_mcp/tools/export_tools.py +1565 -0
  49. package/src/qingflow_mcp/tools/import_tools.py +78 -4
  50. package/src/qingflow_mcp/tools/record_tools.py +551 -165
  51. package/src/qingflow_mcp/tools/resource_read_tools.py +154 -33
  52. package/src/qingflow_mcp/tools/task_context_tools.py +917 -141
  53. package/src/qingflow_mcp/tools/workspace_tools.py +141 -0
@@ -4,7 +4,7 @@ Use the smallest v2 builder tool chain that can finish the task.
4
4
 
5
5
  ## Default path
6
6
 
7
- `resolve -> summary read -> apply -> attach -> publish_verify`
7
+ `summary read -> apply -> publish_verify`
8
8
 
9
9
  Public builder `apply` tools already perform server-side planning, normalization, and dependency checks internally. Do not route normal public builder work through explicit `*_plan` tools.
10
10
 
@@ -21,13 +21,12 @@ If the user asks for multiple forms/modules that relate to each other, this is a
21
21
 
22
22
  ## Resolve
23
23
 
24
- - `package_create`: create a new package only after the user confirms package creation; exact-name duplicates return `noop=true`
25
- - `package_resolve`: exact package lookup by name
26
- - `package_list`: read-only fallback when package resolution is ambiguous
24
+ - `package_get`: read one known package by `package_id`
25
+ - `package_apply`: create or update one package; use `create_if_missing=true` only after explicit user intent
27
26
  - `member_search`: resolve named people from the directory
28
27
  - `role_search`: resolve reusable roles from the directory
29
28
  - `role_create`: create a reusable role when the business owner wants role-based routing
30
- - `app_resolve`: locate an existing app by exactly one selector mode: `app_key`, or `app_name + package_tag_id`
29
+ - `app_resolve`: locate an existing app by exactly one selector mode: `app_key`, or `app_name + package_id`
31
30
 
32
31
  ## Summary reads
33
32
 
@@ -48,21 +47,20 @@ These execute normalized patches and publish by default unless `publish=false`.
48
47
  - `app_flow_apply`: replace workflow
49
48
  - `app_views_apply`: upsert or remove views
50
49
  - `app_charts_apply`: upsert/remove/reorder QingBI charts; charts are immediate-live and do not publish; use `chart_id` when names are not unique
51
- - `portal_apply`: create or replace-update portal pages; use `dash_key` for update mode or `package_tag_id + dash_name` for create mode; sections are replace-only and omission deletes old sections; `publish=false` only guarantees draft/base-info updates
50
+ - `portal_apply`: create or replace-update portal pages; use `dash_key` for update mode or `package_id + dash_name` for create mode; edit mode may omit `sections` for base-info-only updates; when sections are supplied they still use replace semantics
52
51
 
53
52
  ## Explicit post-apply tools
54
53
 
55
- - `package_attach_app`: attach an app to a package with `tag_id + app_key`; do not assume create or publish attaches it
56
54
  - `app_publish_verify`: explicit final publish verification when the user asks for live confirmation
57
55
 
58
56
  ## Decision shortcuts
59
57
 
60
58
  - Create one app inside an existing package:
61
- `package_resolve -> app_resolve -> app_schema_apply -> package_attach_app`
59
+ `package_get -> app_resolve -> app_schema_apply`
62
60
  - Create a brand new package, then create one app in it:
63
- `package_create -> package_resolve -> app_schema_apply -> package_attach_app`
61
+ `package_apply(create_if_missing=true) -> app_schema_apply`
64
62
  - Create a brand new multi-app system/package:
65
- `package_create/resolve -> per-app app_schema_apply -> package_attach_app per app -> relation field patches`
63
+ `package_apply(create_if_missing=true) -> per-app app_schema_apply -> relation field patches`
66
64
  - Update fields on an existing app:
67
65
  `app_resolve -> app_get_fields -> app_schema_apply`
68
66
  - Tidy layout:
@@ -81,6 +79,7 @@ These execute normalized patches and publish by default unless `publish=false`.
81
79
  - Do not handcraft raw Qingflow schema payloads
82
80
  - Do not rely on internal `solution_*` tools in public builder flows
83
81
  - Do not create a new package without first asking the user to confirm package creation
82
+ - Do not regress to `package_create` or `package_attach_app` as the public default story
84
83
  - Do not treat a package/system name as `app_name` when the user clearly wants multiple apps inside it
85
84
  - Do not compress multiple business objects into one app with several text fields
86
85
  - Do not skip summary reads before flow or view work
@@ -1,5 +1,37 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from importlib.metadata import PackageNotFoundError, packages_distributions, version as _dist_version
4
+ from pathlib import Path
5
+
3
6
  __all__ = ["__version__"]
4
7
 
5
- __version__ = "0.2.0b87"
8
+ _FALLBACK_VERSION = "0.2.0b1017"
9
+
10
+
11
+ def _resolve_local_pyproject_version() -> str | None:
12
+ module_path = Path(__file__).resolve()
13
+ for parent in module_path.parents:
14
+ candidate = parent / "pyproject.toml"
15
+ if not candidate.is_file():
16
+ continue
17
+ for line in candidate.read_text(encoding="utf-8").splitlines():
18
+ stripped = line.strip()
19
+ if stripped.startswith("version = "):
20
+ return stripped.split("=", 1)[1].strip().strip('"')
21
+ break
22
+ return None
23
+
24
+
25
+ def _resolve_runtime_version() -> str:
26
+ local_version = _resolve_local_pyproject_version()
27
+ if local_version:
28
+ return local_version
29
+ for dist_name in packages_distributions().get("qingflow_mcp", []):
30
+ try:
31
+ return _dist_version(dist_name)
32
+ except PackageNotFoundError:
33
+ continue
34
+ return _FALLBACK_VERSION
35
+
36
+
37
+ __version__ = _resolve_runtime_version()
@@ -474,6 +474,115 @@ class BackendClient:
474
474
  pass
475
475
  return import_result
476
476
 
477
+ def start_socket_record_export(
478
+ self,
479
+ context: BackendRequestContext,
480
+ *,
481
+ app_key: str,
482
+ view_id: str,
483
+ filter_bean: JSONObject,
484
+ export_config: JSONObject,
485
+ view_key: str | None = None,
486
+ result_amount: int = 0,
487
+ ack_timeout_seconds: float = 8.0,
488
+ ) -> dict[str, Any]:
489
+ try:
490
+ import socketio # type: ignore[import-not-found]
491
+ except ImportError as exc:
492
+ raise QingflowApiError(
493
+ category="config",
494
+ message=f"socket.io client dependency is missing: {exc}",
495
+ )
496
+
497
+ socket_base_url = self._build_socket_base_url(context.base_url)
498
+ export_result: dict[str, Any] = {
499
+ "backend_export_id": None,
500
+ "warnings": [],
501
+ }
502
+ sio = socketio.Client(reconnection=False, logger=False, engineio_logger=False)
503
+ event_name = "excelViewgraph" if view_key else "excel"
504
+ event_args: tuple[Any, ...]
505
+ if view_key:
506
+ event_args = (
507
+ context.token,
508
+ None,
509
+ view_key,
510
+ filter_bean,
511
+ export_config,
512
+ int(result_amount),
513
+ )
514
+ else:
515
+ event_args = (
516
+ context.token,
517
+ app_key,
518
+ filter_bean,
519
+ export_config,
520
+ int(result_amount),
521
+ )
522
+ try:
523
+ sio.connect(
524
+ socket_base_url,
525
+ transports=["websocket"],
526
+ socketio_path="socket.io",
527
+ headers=self._base_headers(
528
+ context.token,
529
+ context.ws_id,
530
+ qf_version=context.qf_version,
531
+ ),
532
+ wait_timeout=ack_timeout_seconds,
533
+ )
534
+ sio.emit("token", context.token)
535
+ sleep(0.2)
536
+ ack = sio.call(
537
+ event_name,
538
+ event_args,
539
+ timeout=ack_timeout_seconds,
540
+ )
541
+ ack_payload = ack[0] if isinstance(ack, list) and ack else ack
542
+ export_id: Any = ack_payload
543
+ if isinstance(ack_payload, dict):
544
+ error_code = ack_payload.get("error")
545
+ ack_message = ack_payload.get("message")
546
+ export_id = ack_payload.get("data")
547
+ if isinstance(export_id, dict):
548
+ export_id = (
549
+ export_id.get("exportId")
550
+ or export_id.get("export_id")
551
+ or export_id.get("id")
552
+ )
553
+ if error_code not in (None, 0):
554
+ raise QingflowApiError(
555
+ category="backend",
556
+ message=str(ack_message or f"socket export rejected with error {error_code}"),
557
+ details={
558
+ "socket_error_code": error_code,
559
+ "app_key": app_key,
560
+ "view_id": view_id,
561
+ "view_key": view_key,
562
+ },
563
+ )
564
+ if not export_id:
565
+ raise QingflowApiError(category="backend", message="socket export ack did not return export_id")
566
+ export_result["backend_export_id"] = str(export_id)
567
+ except Exception as exc:
568
+ message = str(exc)
569
+ if "timeout" in message.lower():
570
+ raise QingflowApiError(
571
+ category="network",
572
+ message="socket export ack timed out",
573
+ details={"error_code": "EXPORT_SOCKET_ACK_TIMEOUT"},
574
+ )
575
+ if isinstance(exc, QingflowApiError):
576
+ raise
577
+ raise QingflowApiError(category="network", message=message or "socket export failed")
578
+ finally:
579
+ try:
580
+ if sio.connected:
581
+ sio.disconnect()
582
+ except Exception:
583
+ pass
584
+ return export_result
585
+
477
586
  def _request_with_meta(
478
587
  self,
479
588
  method: str,
@@ -0,0 +1,282 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+ BUTTON_BACKGROUND_COLORS: tuple[str, ...] = (
7
+ "transparent",
8
+ "#FB9337",
9
+ "#FA6F32",
10
+ "#FAB300",
11
+ "#67C200",
12
+ "#00BD77",
13
+ "#00C5FB",
14
+ "#268BFB",
15
+ "#001A72",
16
+ "#9E64FB",
17
+ "#D164FB",
18
+ "#FB4B51",
19
+ "#FFFFFF",
20
+ )
21
+
22
+ BUTTON_TEXT_COLORS: tuple[str, ...] = (
23
+ "#FB9337",
24
+ "#FA6F32",
25
+ "#FAB300",
26
+ "#67C200",
27
+ "#00BD77",
28
+ "#00C5FB",
29
+ "#268BFB",
30
+ "#001A72",
31
+ "#9E64FB",
32
+ "#D164FB",
33
+ "#FB4B51",
34
+ "#494F57",
35
+ "#FFFFFF",
36
+ )
37
+
38
+ BUTTON_ICONS: tuple[str, ...] = (
39
+ "ex-print",
40
+ "ex-attachment",
41
+ "ex-handoff",
42
+ "ex-delete",
43
+ "ex-copy",
44
+ "ex-share",
45
+ "ex-edit2",
46
+ "ex-admin-outlined",
47
+ "ex-stamp",
48
+ "ex-kanban",
49
+ "ex-update",
50
+ "ex-tickincircle-outlined",
51
+ "ex-crossincircle-outlined",
52
+ "ex-plus-circle",
53
+ "ex-logout",
54
+ "ex-check",
55
+ "ex-rename",
56
+ "ex-locked",
57
+ "ex-shift",
58
+ "ex-new-tabpage",
59
+ "ex-cross",
60
+ "ex-switch",
61
+ "ex-layer",
62
+ "ex-import",
63
+ "ex-pin-outlined",
64
+ "ex-disabled",
65
+ "ex-duplicate",
66
+ "ex-heart-outlined",
67
+ "ex-plus",
68
+ "ex-save",
69
+ "ex-upload",
70
+ "ex-upgrade1",
71
+ "ex-downgrade",
72
+ "ex-unfold",
73
+ "ex-fold",
74
+ "ex-sort",
75
+ "ex-menu-control",
76
+ "ex-download",
77
+ "ex-insert-below",
78
+ "ex-left-outlined-double",
79
+ "ex-right-outlined-double",
80
+ "ex-recovery",
81
+ "ex-layout",
82
+ "ex-search",
83
+ "ex-preview",
84
+ "ex-invisible",
85
+ "ex-carboncopy",
86
+ "ex-basicInfo",
87
+ "ex-fillIn",
88
+ "ex-refresh",
89
+ "ex-display",
90
+ "ex-message",
91
+ "ex-edit",
92
+ "ex-heart-filled",
93
+ "ex-pin-filled",
94
+ "ex-transfer",
95
+ "ex-cross-circle",
96
+ "ex-clock",
97
+ "ex-admin-filled",
98
+ "ex-tickincircle-circle",
99
+ "ex-all-application",
100
+ "ex-help-filled",
101
+ "ex-streamline",
102
+ "ex-table",
103
+ "ex-rowheight-short",
104
+ "ex-rowheight-medium",
105
+ "ex-rowheight-tall",
106
+ "ex-rowheight-tallest",
107
+ )
108
+
109
+ BUTTON_STYLE_PRESETS: tuple[dict[str, Any], ...] = (
110
+ {
111
+ "key": "primary_blue",
112
+ "label": "Primary Blue",
113
+ "button_type": "default",
114
+ "background_color": "#268BFB",
115
+ "text_color": "#FFFFFF",
116
+ "recommended_icons": ["ex-plus-circle", "ex-plus"],
117
+ },
118
+ {
119
+ "key": "text_blue",
120
+ "label": "Text Blue",
121
+ "button_type": "text",
122
+ "background_color": "transparent",
123
+ "text_color": "#268BFB",
124
+ "recommended_icons": ["ex-share", "ex-new-tabpage"],
125
+ },
126
+ {
127
+ "key": "warning_orange",
128
+ "label": "Warning Orange",
129
+ "button_type": "default",
130
+ "background_color": "#FB9337",
131
+ "text_color": "#FFFFFF",
132
+ "recommended_icons": ["ex-message", "ex-clock"],
133
+ },
134
+ {
135
+ "key": "danger_red",
136
+ "label": "Danger Red",
137
+ "button_type": "default",
138
+ "background_color": "#FB4B51",
139
+ "text_color": "#FFFFFF",
140
+ "recommended_icons": ["ex-delete", "ex-cross-circle"],
141
+ },
142
+ {
143
+ "key": "neutral_outline",
144
+ "label": "Neutral Outline",
145
+ "button_type": "default",
146
+ "background_color": "#FFFFFF",
147
+ "text_color": "#494F57",
148
+ "recommended_icons": ["ex-edit", "ex-search"],
149
+ },
150
+ {
151
+ "key": "secondary_gray",
152
+ "label": "Secondary Gray",
153
+ "button_type": "text",
154
+ "background_color": "transparent",
155
+ "text_color": "#494F57",
156
+ "recommended_icons": ["ex-edit", "ex-search"],
157
+ },
158
+ )
159
+
160
+ _PRESET_BY_KEY: dict[str, dict[str, Any]] = {
161
+ str(item["key"]).strip(): dict(item) for item in BUTTON_STYLE_PRESETS
162
+ }
163
+
164
+ _COLOR_FAMILY_BY_VALUE: dict[str, str] = {
165
+ "transparent": "neutral",
166
+ "#FB9337": "orange",
167
+ "#FA6F32": "orange",
168
+ "#FAB300": "yellow",
169
+ "#67C200": "green",
170
+ "#00BD77": "green",
171
+ "#00C5FB": "cyan",
172
+ "#268BFB": "blue",
173
+ "#001A72": "blue",
174
+ "#9E64FB": "purple",
175
+ "#D164FB": "purple",
176
+ "#FB4B51": "red",
177
+ "#FFFFFF": "white",
178
+ "#494F57": "gray",
179
+ }
180
+
181
+
182
+ def _normalize_color_value(value: str | None) -> str | None:
183
+ normalized = str(value or "").strip()
184
+ if not normalized:
185
+ return None
186
+ lowered = normalized.lower().replace(" ", "")
187
+ if lowered == "transparent":
188
+ return "transparent"
189
+ if lowered in {
190
+ "#fff",
191
+ "#ffffff",
192
+ "white",
193
+ "rgb(255,255,255)",
194
+ "rgba(255,255,255,1)",
195
+ }:
196
+ return "#FFFFFF"
197
+ if normalized.startswith("#"):
198
+ return normalized.upper()
199
+ return normalized
200
+
201
+
202
+ def button_style_catalog_payload() -> dict[str, Any]:
203
+ return {
204
+ "icons": [{"value": icon, "label": icon} for icon in BUTTON_ICONS],
205
+ "background_colors": [
206
+ {
207
+ "value": color,
208
+ "label": color,
209
+ "family": _COLOR_FAMILY_BY_VALUE.get(color, "custom"),
210
+ }
211
+ for color in BUTTON_BACKGROUND_COLORS
212
+ ],
213
+ "text_colors": [
214
+ {
215
+ "value": color,
216
+ "label": color,
217
+ "family": _COLOR_FAMILY_BY_VALUE.get(color, "custom"),
218
+ }
219
+ for color in BUTTON_TEXT_COLORS
220
+ ],
221
+ "presets": [deepcopy(item) for item in BUTTON_STYLE_PRESETS],
222
+ }
223
+
224
+
225
+ def resolve_button_style(
226
+ *,
227
+ style_preset: str | None,
228
+ button_icon: str | None,
229
+ background_color: str | None,
230
+ text_color: str | None,
231
+ require_complete_style: bool,
232
+ ) -> dict[str, str | None]:
233
+ preset_key = str(style_preset or "").strip() or None
234
+ resolved_icon = str(button_icon or "").strip() or None
235
+ resolved_background = _normalize_color_value(background_color)
236
+ resolved_text = _normalize_color_value(text_color)
237
+
238
+ if preset_key is not None:
239
+ preset = _PRESET_BY_KEY.get(preset_key)
240
+ if preset is None:
241
+ raise ValueError(
242
+ "unsupported style_preset; use button_style_catalog_get to inspect available presets"
243
+ )
244
+ if resolved_background is None:
245
+ resolved_background = str(preset["background_color"])
246
+ if resolved_text is None:
247
+ resolved_text = str(preset["text_color"])
248
+ if resolved_icon is None:
249
+ recommended = preset.get("recommended_icons") or []
250
+ if recommended:
251
+ resolved_icon = str(recommended[0]).strip() or None
252
+
253
+ if require_complete_style:
254
+ missing: list[str] = []
255
+ if resolved_icon is None:
256
+ missing.append("button_icon")
257
+ if resolved_background is None:
258
+ missing.append("background_color")
259
+ if resolved_text is None:
260
+ missing.append("text_color")
261
+ if missing:
262
+ raise ValueError(f"button style requires {', '.join(missing)}")
263
+
264
+ if resolved_icon is not None and resolved_icon not in BUTTON_ICONS:
265
+ raise ValueError(
266
+ "unsupported button_icon; use button_style_catalog_get to inspect available icons"
267
+ )
268
+ if resolved_background is not None and resolved_background not in BUTTON_BACKGROUND_COLORS:
269
+ raise ValueError(
270
+ "unsupported background_color; current frontend only supports template colors from button_style_catalog_get"
271
+ )
272
+ if resolved_text is not None and resolved_text not in BUTTON_TEXT_COLORS:
273
+ raise ValueError(
274
+ "unsupported text_color; current frontend only supports template colors from button_style_catalog_get"
275
+ )
276
+
277
+ return {
278
+ "style_preset": preset_key,
279
+ "button_icon": resolved_icon,
280
+ "background_color": resolved_background,
281
+ "text_color": resolved_text,
282
+ }
@@ -5,6 +5,7 @@ from typing import Any
5
5
 
6
6
  from pydantic import AliasChoices, Field, model_validator
7
7
 
8
+ from .button_style_catalog import resolve_button_style
8
9
  from ..solution.spec_models import StrictModel
9
10
 
10
11
 
@@ -819,6 +820,10 @@ class FieldMutation(StrictModel):
819
820
  validation_alias=AliasChoices("custom_button_text", "customButtonText", "custom_btn_text", "customBtnText"),
820
821
  )
821
822
  subfields: list[FieldPatch] | None = None
823
+ subfield_updates: list["FieldUpdatePatch"] | None = Field(
824
+ default=None,
825
+ validation_alias=AliasChoices("subfield_updates", "subfieldUpdates"),
826
+ )
822
827
 
823
828
  @model_validator(mode="after")
824
829
  def validate_shape(self) -> "FieldMutation":
@@ -848,8 +853,12 @@ class FieldMutation(StrictModel):
848
853
  or self.custom_button_text is not None
849
854
  ):
850
855
  raise ValueError("code_block_config, code_block_binding, auto_trigger, custom_button_text_enabled, and custom_button_text are only allowed for code_block fields")
851
- if self.type == PublicFieldType.subtable and not self.subfields:
852
- raise ValueError("subtable field requires subfields")
856
+ if self.type == PublicFieldType.subtable and not self.subfields and not self.subfield_updates:
857
+ raise ValueError("subtable field requires subfields or subfield_updates")
858
+ if self.type is not None and self.type != PublicFieldType.subtable and self.subfield_updates:
859
+ raise ValueError("subfield_updates are only allowed for subtable fields")
860
+ if self.subfields and self.subfield_updates:
861
+ raise ValueError("subfields and subfield_updates cannot be used together")
853
862
  return self
854
863
 
855
864
  @model_validator(mode="before")
@@ -1172,10 +1181,10 @@ def _is_white_button_color(value: str | None) -> bool:
1172
1181
 
1173
1182
  class CustomButtonPatch(StrictModel):
1174
1183
  button_text: str = Field(validation_alias=AliasChoices("button_text", "buttonText"))
1175
- background_color: str = Field(validation_alias=AliasChoices("background_color", "backgroundColor"))
1176
- text_color: str = Field(validation_alias=AliasChoices("text_color", "textColor"))
1177
- button_icon: str = Field(validation_alias=AliasChoices("button_icon", "buttonIcon"))
1178
- icon_color: str | None = Field(default=None, validation_alias=AliasChoices("icon_color", "iconColor"))
1184
+ background_color: str | None = Field(default=None, validation_alias=AliasChoices("background_color", "backgroundColor"))
1185
+ text_color: str | None = Field(default=None, validation_alias=AliasChoices("text_color", "textColor"))
1186
+ button_icon: str | None = Field(default=None, validation_alias=AliasChoices("button_icon", "buttonIcon"))
1187
+ style_preset: str | None = Field(default=None, validation_alias=AliasChoices("style_preset", "stylePreset"))
1179
1188
  trigger_action: PublicButtonTriggerAction = Field(validation_alias=AliasChoices("trigger_action", "triggerAction"))
1180
1189
  trigger_link_url: str | None = Field(default=None, validation_alias=AliasChoices("trigger_link_url", "triggerLinkUrl"))
1181
1190
  trigger_add_data_config: CustomButtonAddDataConfigPatch | None = Field(
@@ -1196,8 +1205,29 @@ class CustomButtonPatch(StrictModel):
1196
1205
  validation_alias=AliasChoices("trigger_wings_config", "triggerWingsConfig"),
1197
1206
  )
1198
1207
 
1208
+ @model_validator(mode="before")
1209
+ @classmethod
1210
+ def normalize_aliases(cls, value: Any) -> Any:
1211
+ if not isinstance(value, dict):
1212
+ return value
1213
+ payload = dict(value)
1214
+ if "icon_color" in payload or "iconColor" in payload:
1215
+ raise ValueError("icon_color is not supported; icon color follows text_color")
1216
+ return payload
1217
+
1199
1218
  @model_validator(mode="after")
1200
1219
  def validate_shape(self) -> "CustomButtonPatch":
1220
+ resolved = resolve_button_style(
1221
+ style_preset=self.style_preset,
1222
+ button_icon=self.button_icon,
1223
+ background_color=self.background_color,
1224
+ text_color=self.text_color,
1225
+ require_complete_style=True,
1226
+ )
1227
+ self.style_preset = resolved["style_preset"]
1228
+ self.button_icon = resolved["button_icon"]
1229
+ self.background_color = resolved["background_color"]
1230
+ self.text_color = resolved["text_color"]
1201
1231
  if self.trigger_action == PublicButtonTriggerAction.link and not str(self.trigger_link_url or "").strip():
1202
1232
  raise ValueError("link buttons require trigger_link_url")
1203
1233
  if self.trigger_action == PublicButtonTriggerAction.add_data and self.trigger_add_data_config is None:
@@ -1217,7 +1247,7 @@ class ViewButtonBindingPatch(StrictModel):
1217
1247
  button_id: int = Field(validation_alias=AliasChoices("button_id", "buttonId", "id"))
1218
1248
  button_text: str | None = Field(default=None, validation_alias=AliasChoices("button_text", "buttonText"))
1219
1249
  button_icon: str | None = Field(default=None, validation_alias=AliasChoices("button_icon", "buttonIcon"))
1220
- icon_color: str | None = Field(default=None, validation_alias=AliasChoices("icon_color", "iconColor"))
1250
+ style_preset: str | None = Field(default=None, validation_alias=AliasChoices("style_preset", "stylePreset"))
1221
1251
  background_color: str | None = Field(default=None, validation_alias=AliasChoices("background_color", "backgroundColor"))
1222
1252
  text_color: str | None = Field(default=None, validation_alias=AliasChoices("text_color", "textColor"))
1223
1253
  trigger_action: str | None = Field(default=None, validation_alias=AliasChoices("trigger_action", "triggerAction"))
@@ -1236,6 +1266,8 @@ class ViewButtonBindingPatch(StrictModel):
1236
1266
  if not isinstance(value, dict):
1237
1267
  return value
1238
1268
  payload = dict(value)
1269
+ if "icon_color" in payload or "iconColor" in payload:
1270
+ raise ValueError("icon_color is not supported; icon color follows text_color")
1239
1271
  raw_button_type = payload.get("button_type", payload.get("buttonType"))
1240
1272
  if isinstance(raw_button_type, str):
1241
1273
  normalized_type = raw_button_type.strip().lower()
@@ -1257,6 +1289,21 @@ class ViewButtonBindingPatch(StrictModel):
1257
1289
 
1258
1290
  @model_validator(mode="after")
1259
1291
  def validate_shape(self) -> "ViewButtonBindingPatch":
1292
+ require_complete_style = self.button_type == PublicViewButtonType.system or any(
1293
+ str(value or "").strip()
1294
+ for value in (self.style_preset, self.button_icon, self.background_color, self.text_color)
1295
+ )
1296
+ resolved = resolve_button_style(
1297
+ style_preset=self.style_preset,
1298
+ button_icon=self.button_icon,
1299
+ background_color=self.background_color,
1300
+ text_color=self.text_color,
1301
+ require_complete_style=require_complete_style,
1302
+ )
1303
+ self.style_preset = resolved["style_preset"]
1304
+ self.button_icon = resolved["button_icon"]
1305
+ self.background_color = resolved["background_color"]
1306
+ self.text_color = resolved["text_color"]
1260
1307
  if self.button_type == PublicViewButtonType.system:
1261
1308
  missing = [
1262
1309
  field_name
@@ -1528,14 +1575,16 @@ class PortalApplyRequest(StrictModel):
1528
1575
  raise ValueError("package_tag_id is required when dash_key is empty")
1529
1576
  if not self.dash_key and not self.dash_name:
1530
1577
  raise ValueError("dash_name is required when creating a portal")
1531
- if not self.sections:
1532
- raise ValueError("portal apply requires a non-empty sections list")
1578
+ if not self.dash_key and not self.sections:
1579
+ raise ValueError("portal apply requires a non-empty sections list when creating a portal")
1533
1580
  if self.visibility is not None and self.auth is not None:
1534
1581
  raise ValueError("visibility and auth cannot be provided together")
1535
1582
  return self
1536
1583
 
1537
1584
 
1538
1585
  FieldPatch.model_rebuild()
1586
+ FieldMutation.model_rebuild()
1587
+ FieldUpdatePatch.model_rebuild()
1539
1588
 
1540
1589
 
1541
1590
  class AppGetResponse(StrictModel):