@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.
- package/README.md +2 -2
- package/docs/local-agent-install.md +9 -3
- package/npm/lib/runtime.mjs +10 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-builder/SKILL.md +88 -184
- package/skills/qingflow-app-builder/references/create-app.md +15 -34
- package/skills/qingflow-app-builder/references/gotchas.md +3 -3
- package/skills/qingflow-app-builder/references/solution-playbooks.md +1 -2
- package/skills/qingflow-app-builder/references/tool-selection.md +9 -10
- package/src/qingflow_mcp/__init__.py +33 -1
- package/src/qingflow_mcp/backend_client.py +109 -0
- package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
- package/src/qingflow_mcp/builder_facade/models.py +58 -9
- package/src/qingflow_mcp/builder_facade/service.py +1711 -240
- package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
- package/src/qingflow_mcp/cli/commands/app.py +47 -1
- package/src/qingflow_mcp/cli/commands/auth.py +63 -0
- package/src/qingflow_mcp/cli/commands/builder.py +11 -3
- package/src/qingflow_mcp/cli/commands/exports.py +111 -0
- package/src/qingflow_mcp/cli/commands/record.py +5 -5
- package/src/qingflow_mcp/cli/commands/task.py +701 -27
- package/src/qingflow_mcp/cli/commands/workspace.py +84 -0
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/cli/formatters.py +424 -50
- package/src/qingflow_mcp/cli/interaction.py +72 -0
- package/src/qingflow_mcp/cli/main.py +11 -1
- package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
- package/src/qingflow_mcp/cli/terminal_ui.py +218 -0
- package/src/qingflow_mcp/config.py +1 -1
- package/src/qingflow_mcp/errors.py +4 -4
- package/src/qingflow_mcp/export_store.py +14 -0
- package/src/qingflow_mcp/id_utils.py +49 -0
- package/src/qingflow_mcp/public_surface.py +16 -1
- package/src/qingflow_mcp/response_trim.py +394 -9
- package/src/qingflow_mcp/server.py +26 -0
- package/src/qingflow_mcp/server_app_builder.py +15 -1
- package/src/qingflow_mcp/server_app_user.py +113 -0
- package/src/qingflow_mcp/session_store.py +126 -21
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +2 -2
- package/src/qingflow_mcp/solution/executor.py +2 -2
- package/src/qingflow_mcp/tools/ai_builder_tools.py +107 -34
- package/src/qingflow_mcp/tools/app_tools.py +1 -0
- package/src/qingflow_mcp/tools/auth_tools.py +243 -9
- package/src/qingflow_mcp/tools/base.py +6 -2
- package/src/qingflow_mcp/tools/code_block_tools.py +2 -2
- package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
- package/src/qingflow_mcp/tools/export_tools.py +1565 -0
- package/src/qingflow_mcp/tools/import_tools.py +78 -4
- package/src/qingflow_mcp/tools/record_tools.py +551 -165
- package/src/qingflow_mcp/tools/resource_read_tools.py +154 -33
- package/src/qingflow_mcp/tools/task_context_tools.py +917 -141
- 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
|
-
`
|
|
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
|
-
- `
|
|
25
|
-
- `
|
|
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 +
|
|
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 `
|
|
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
|
-
`
|
|
59
|
+
`package_get -> app_resolve -> app_schema_apply`
|
|
62
60
|
- Create a brand new package, then create one app in it:
|
|
63
|
-
`
|
|
61
|
+
`package_apply(create_if_missing=true) -> app_schema_apply`
|
|
64
62
|
- Create a brand new multi-app system/package:
|
|
65
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):
|