@josephyan/qingflow-app-builder-mcp 1.1.26 → 1.1.28

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.
@@ -6,7 +6,7 @@ This playbook follows the current public builder surface, not legacy package hel
6
6
  Do not use this playbook when the user is really asking for a system/package with multiple forms or modules. In that case:
7
7
 
8
8
  1. read or create the package through `package_get` / `package_apply`
9
- 2. create each app separately
9
+ 2. create related apps together with one `app_schema_apply(apps=[...])`
10
10
  3. keep package ownership on the public `package_id` path instead of a separate attach step
11
11
  4. add relation fields between apps
12
12
 
@@ -85,6 +85,12 @@ Apply schema for a new app:
85
85
  {"name": "订单金额", "type": "amount"},
86
86
  {"name": "状态", "type": "single_select", "options": ["草稿", "进行中", "已完成"], "required": true}
87
87
  ],
88
+ "form": {
89
+ "sections": [
90
+ {"title": "基础信息", "rows": [["订单编号", "客户名称"], ["状态"]]},
91
+ {"title": "金额与附件", "rows": [["订单金额"], ["订单封面"]]}
92
+ ]
93
+ },
88
94
  "update_fields": [],
89
95
  "remove_fields": []
90
96
  }
@@ -105,13 +111,18 @@ Apply schema for multiple apps in one call:
105
111
  {
106
112
  "client_key": "customer",
107
113
  "app_name": "客户",
114
+ "icon": "user-group",
115
+ "color": "blue",
108
116
  "add_fields": [
109
117
  {"name": "客户名称", "type": "text", "required": true, "as_data_title": true}
110
- ]
118
+ ],
119
+ "form": {"sections": [{"title": "基础信息", "rows": [["客户名称"]]}]}
111
120
  },
112
121
  {
113
122
  "client_key": "order",
114
123
  "app_name": "订单",
124
+ "icon": "delivery-box-1",
125
+ "color": "green",
115
126
  "add_fields": [
116
127
  {"name": "订单编号", "type": "text", "required": true, "as_data_title": true},
117
128
  {
@@ -121,7 +132,8 @@ Apply schema for multiple apps in one call:
121
132
  "display_field": {"name": "客户名称"},
122
133
  "visible_fields": [{"name": "客户名称"}]
123
134
  }
124
- ]
135
+ ],
136
+ "form": {"sections": [{"title": "基础信息", "rows": [["订单编号", "关联客户"]]}]}
125
137
  }
126
138
  ]
127
139
  }
@@ -22,7 +22,7 @@
22
22
 
23
23
  - Always pass `publish: true` explicitly on `app_schema_apply`, `app_layout_apply`, `app_flow_apply`, `app_views_apply`, and `portal_apply` — do not rely on the default
24
24
  - Pass `publish: false` on those five tools only when the user explicitly wants to leave changes in draft
25
- - `app_custom_buttons_apply` and `app_associated_resources_apply` publish after at least one write succeeds and do not accept a `publish` parameter
25
+ - `app_associated_resources_apply` publishes after at least one write succeeds and does not accept a `publish` parameter
26
26
  - `app_charts_apply` is immediate-live and has no publish step
27
27
  - `app_publish_verify` is for explicit final verification, not the default next step after every write
28
28
 
@@ -34,20 +34,16 @@
34
34
  - Creating a chart with `app_charts_apply` does not automatically show it in the Qingflow app UI; attach it separately if display is required.
35
35
  - `target` and `table` remain compatibility aliases for QingBI `indicator` and `detail`; use the real QingBI chart type when you need a specific type such as `summary`, `columnar`, `stacked_bar`, `scatter`, or `dualaxes`.
36
36
 
37
- ## Custom buttons
37
+ ## View actions
38
38
 
39
- - Use `app_custom_buttons_apply` for button creation/update/removal and view placement; do not split placement into `app_views_apply.buttons` unless you are maintaining a legacy patch.
40
- - When changing one existing button parameter, use `patch_buttons` with `set/unset`. Do not send a partial `upsert_buttons` object and expect the backend to preserve hidden required fields.
41
- - For add-data buttons, prefer semantic `trigger_add_data_config.target_app_key`, `field_mappings`, and `default_values`.
39
+ - Put business actions directly in the target view's `action_buttons`.
40
+ - For add-data actions, use semantic `target_app_key`, `field_mappings`, and `default_values`.
42
41
  - Do not handwrite raw `que_relation` unless you are preserving an existing backend config. If `field_mappings` and `que_relation` are mixed, the tool blocks the write.
43
42
  - `field_mappings.source_field` can use source app fields and supported system fields such as `数据ID` (`field_id=-17`) and `编号` (`field_id=0`).
44
43
  - To fill a target relation field with the current source record, map `{"source_field": "数据ID", "target_field": "目标引用字段"}`. Use `default_values` only for static constants.
45
44
  - For field type compatibility, read `references/match-rules.md`.
46
45
  - Default button placements are `header` and `detail`; these map to the frontend's header and detail button areas.
47
- - `view_configs[].buttons` is required in merge mode. Do not send a view config with only `view_key`; it is blocked to avoid no-op writes and accidental publish.
48
46
  - Builder view configs use raw `view_key` from `app_get.views[].view_key`; do not prefix it with `custom:`. The `custom:<viewKey>` form is for record-data `view_id`.
49
- - View button bindings merge by default. Use `view_configs[].mode="replace"` to replace the full set, or pass an explicit empty `buttons: []` when you intend to clear all custom button bindings for that view.
50
- - Advanced view button bindings can include `button_limit`, `button_formula`, `button_formula_type`, and `print_tpls`, but keep ordinary buttons simple unless the user asks for conditional visibility or print templates.
51
47
  - `placement=list` configures row/list buttons. The tool maps it to the backend `INSIDE` button position, not a raw `LIST` config type.
52
48
 
53
49
  ## Associated resources
@@ -68,9 +64,9 @@
68
64
 
69
65
  ## Partial update discipline
70
66
 
71
- - Existing views, custom buttons, associated resources, and charts support the same public partial pattern: `patch_*[].set` plus optional `patch_*[].unset`.
67
+ - Existing views, associated resources, and charts support the same public partial pattern: `patch_*[].set` plus optional `patch_*[].unset`.
72
68
  - The backend may still save a full payload. The MCP/CLI patch path is responsible for reading current config and preserving fields the user did not mention.
73
- - Do not use `upsert_views`, `upsert_buttons`, `upsert_resources`, or `upsert_charts` for a tiny parameter replacement unless you are deliberately providing the full desired target config.
69
+ - Do not use `upsert_views`, `upsert_resources`, or `upsert_charts` for a tiny parameter replacement unless you are deliberately providing the full desired target config.
74
70
  - `app_layout_apply(mode=merge)` is already a safe layout merge path; `mode=replace` is full layout replacement.
75
71
  - `portal_apply` without `sections` is base-info-only. Supplying `sections` replaces the portal sections list.
76
72
  - `app_flow_apply` is intentionally replace-only for the public linear workflow graph.
@@ -1,13 +1,13 @@
1
1
  # Builder Match Rules
2
2
 
3
- Use this reference for builder-side field matching rules in custom buttons and associated view/report filters.
3
+ Use this reference for builder-side field matching rules in view actions and associated view/report filters.
4
4
 
5
5
  This is not the same thing as `record_access` analysis filters or normal view `filters`.
6
6
 
7
7
  ## Where These Rules Apply
8
8
 
9
- - `app_custom_buttons_apply.upsert_buttons[].trigger_add_data_config.field_mappings`
10
- - `app_custom_buttons_apply.patch_buttons[].set.trigger_add_data_config.field_mappings`
9
+ - `app_views_apply.views[].action_buttons[].field_mappings`
10
+ - `app_views_apply.patch_views[].set.action_buttons[].field_mappings`
11
11
  - `app_associated_resources_apply.upsert_resources[].match_mappings`
12
12
  - `app_associated_resources_apply.patch_resources[].set.match_mappings`
13
13
 
@@ -24,7 +24,7 @@ System fields can participate in mappings:
24
24
 
25
25
  Explicit selectors such as `{"field_id": -17}` and `{"field_id": 0}` are allowed and remove ambiguity.
26
26
 
27
- ## Custom Buttons
27
+ ## View Actions
28
28
 
29
29
  Use `field_mappings` for dynamic values copied from the current source record:
30
30
 
@@ -43,21 +43,25 @@ Use `field_mappings` for dynamic values copied from the current source record:
43
43
 
44
44
  Use `default_values` only for static constants. Do not use `default_values` as the preferred way to pass the current record.
45
45
 
46
- For an existing button, put the same semantic config under `patch_buttons[].set`:
46
+ For an existing view, patch the view's action list through `patch_views[].set.action_buttons`:
47
47
 
48
48
  ```json
49
49
  {
50
- "button_id": 3858041,
50
+ "view_key": "VIEW_KEY",
51
51
  "set": {
52
- "trigger_add_data_config": {
53
- "target_app_key": "WORKLOG_APP",
54
- "field_mappings": [
55
- {"source_field": "数据ID", "target_field": "关联员工"}
56
- ],
57
- "default_values": {
58
- "状态": "待提交"
52
+ "action_buttons": [
53
+ {
54
+ "text": "新建工时",
55
+ "action": "add_data",
56
+ "target_app_key": "WORKLOG_APP",
57
+ "field_mappings": [
58
+ {"source_field": "数据ID", "target_field": "关联员工"}
59
+ ],
60
+ "default_values": {
61
+ "状态": "待提交"
62
+ }
59
63
  }
60
- }
64
+ ]
61
65
  }
62
66
  }
63
67
  ```
@@ -39,14 +39,14 @@ It is not a user-facing product spec. It exists to prevent skill drift.
39
39
  - `app_layout_apply`
40
40
  - `app_flow_apply`
41
41
  - `app_views_apply`
42
- - `app_custom_buttons_apply`
43
42
  - `app_associated_resources_apply`
44
43
  - `app_charts_apply`
45
44
  - `portal_apply`
46
45
  - `app_publish_verify`
47
46
  - `portal_apply` edit mode may omit `sections` for base-info-only updates
48
47
  - `app_charts_apply.visibility` is a public capability and should be treated as a base-only visibility update
49
- - Existing object parameter replacement should use `patch_views`, `patch_buttons`, `patch_resources`, and `patch_charts`; `upsert_*` is for creation or full target config
48
+ - View actions are declared through `app_views_apply` `action_buttons`
49
+ - Existing object parameter replacement should use `patch_views`, `patch_resources`, and `patch_charts`; `upsert_*` is for creation or full target config
50
50
  - `app_get.editability` uses:
51
51
  - `can_edit_app_base`
52
52
  - `can_edit_form`
@@ -38,7 +38,6 @@ All `app_get_*` tools accept either `app_key` (single) or `app_keys[]` (batch).
38
38
  - `app_get_views`: current view names, types, columns, group-by
39
39
  - `app_get_flow`: workflow enabled state, full spec (nodes + transitions)
40
40
  - `app_get_charts`: current chart ids, names, types, order
41
- - `app_get_buttons`: current custom button list (draft state)
42
41
  - `app_get_associated_resources`: current associated resource pool (draft state)
43
42
  - `app_publish_verify`: publish state and package tag verification — accepts `app_keys[]` for multi-app batch
44
43
  - `portal_get`: current portal config detail and component inventory
@@ -48,15 +47,14 @@ All `app_get_*` tools accept either `app_key` (single) or `app_keys[]` (batch).
48
47
  These execute normalized patches. Some app apply tools publish by default and still accept `publish=false`; custom button and associated-resource apply publish after at least one write succeeds and do not expose that switch.
49
48
 
50
49
  - `app_schema_apply`: create app shell or change fields
51
- - `app_layout_apply`: merge or replace layout; use `apps[]` (each item `{app_key, mode?, sections}`) for multi-app batch
50
+ - `app_layout_apply`: layout-only maintenance for existing apps; new app layout belongs in `app_schema_apply` `form.sections`
52
51
  - `app_flow_apply`: replace full workflow spec; use `patch_nodes[]` (`{id, set, unset}`) to update specific nodes without rewriting the full spec
53
- - `app_views_apply`: use `patch_views` for existing-view parameter replacement; use `upsert_views` for creation or full target config; remove views by key; new views default associated report/view display to visible with `limit_type="all"`; use `apps[]` for multi-app batch
54
- - `app_custom_buttons_apply`: use `patch_buttons` for existing-button parameter replacement; use `upsert_buttons` for creation or full target config; configure add-data field mappings/default values; bind buttons to header/detail/list view positions; `placement=list` maps to the backend `INSIDE` row/list button position; merge-mode view configs require `buttons`; use `view_configs[].mode="replace"` or `buttons=[]` to clear a view's custom button bindings. For child-record creation linked to the current source record, map `source_field: "数据ID"` to the target relation field. Use `apps[]` for multi-app batch.
52
+ - `app_views_apply`: use top-level `views[]` for same-app/cross-app batch create, patch, and remove; view business actions belong in each view item's `action_buttons`
55
53
  - `app_associated_resources_apply`: attach existing BI reports/views to the Qingflow app associated-resource pool and per-view display area; it does not create or edit QingBI report bodies/configs. Use `patch_resources` for existing associated-resource parameter replacement; use `upsert_resources` for creation or full target config; `view_configs`, remove, and reorder may reference existing resources by internal `associated_item_id` or by `chart_id`/`chart_key`/`view_key`; use `match_mappings` for associated view/report filters; publishes after successful writes; omit raw `sourceType`, and use `report_source="dataset"` only to attach an existing BI dataset report. Before `upsert_resources`, read `app_get.associated_resources` and reuse an existing matching `target_app_key + view_key/chart_key`; repeated upsert can create duplicates because `client_key` is only valid inside one apply call. Use `apps[]` for multi-app batch.
56
54
  - `app_charts_apply`: create/edit/remove/reorder app-source QingBI report bodies/configs with `dataSourceType=qingflow`; it does not create/edit dataset BI reports and does not attach reports to Qingflow app associated-resource display. Use `patch_charts` for existing-chart parameter replacement; use `upsert_charts` for creation or full target config; supports `target/table` aliases plus QingBI chart types such as `summary`, `columnar`, `area`, `funnel`, `radar`, `scatter`, `dualaxes`, and `map`; charts are immediate-live and do not publish; use `chart_id` when names are not unique; use `apps[]` for multi-app batch
57
- - `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; sections use a 24-column PC grid — all components in the same row (same `y`) must share the same `rows` value and their `cols` must sum to exactly 24; mismatched `rows` causes height misalignment, `cols` under 24 leaves trailing blank space; use `patch_sections[]` (`{chart_ref/view_ref/order, set, unset}`) to update individual sections without replacing all
55
+ - `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; sections use a 24-column PC grid — all components in the same row (same `y`) must share the same `rows` value and their `cols` must sum to exactly 24; mismatched `rows` causes height misalignment, `cols` under 24 leaves trailing blank space; use `patch_sections[]` (`{chart/view_ref/order, set, unset}`) to update individual sections without replacing all
58
56
 
59
- For object-level updates, the safe partial syntax is `patch_*` with the object's real selector field plus `set` and optional `unset`. `selector` is only a concept, not a literal key. Examples: `patch_views: [{"view_key": "VIEW_KEY", "set": {...}}]`, `patch_buttons: [{"button_id": 1001, "set": {...}}]`, `patch_resources: [{"associated_item_id": 123, "set": {...}}]`, `patch_charts: [{"chart_id": 456, "set": {...}}]`, `patch_nodes: [{"id": "approve_1", "set": {...}}]`, `patch_sections: [{"chart_ref": {"chart_key": "ck_001"}, "set": {"title": "新标题"}}]`. The tool reads the current backend config, merges the patch, then submits the full backend payload internally. Do not send a partial `upsert_*` and expect missing required fields to be preserved.
57
+ For object-level updates, the safe partial syntax is `patch_*` with the object's real selector field plus `set` and optional `unset`. `selector` is only a concept, not a literal key. Examples: `patch_views: [{"view_key": "VIEW_KEY", "set": {...}}]`, `patch_resources: [{"associated_item_id": 123, "set": {...}}]`, `patch_charts: [{"chart_id": 456, "set": {...}}]`, `patch_nodes: [{"id": "approve_1", "set": {...}}]`, `patch_sections: [{"chart": {"chart_key": "ck_001"}, "set": {"title": "新标题"}}]`. The tool reads the current backend config, merges the patch, then submits the full backend payload internally. Do not send a partial `upsert_*` and expect missing required fields to be preserved.
60
58
 
61
59
  ## Explicit post-apply tools
62
60
 
@@ -69,19 +67,19 @@ For object-level updates, the safe partial syntax is `patch_*` with the object's
69
67
  - Create a brand new package, then create one app in it:
70
68
  `package_apply(create_if_missing=true) -> app_schema_apply`
71
69
  - Create a brand new multi-app system/package:
72
- `package_apply(create_if_missing=true) -> app_schema_apply(apps[])`
70
+ `package_apply(create_if_missing=true) -> app_schema_apply(apps[] with form.sections)`
73
71
  - Update fields on an existing app:
74
72
  `app_resolve -> app_get_fields -> app_schema_apply`
75
73
  - Tidy layout:
76
- `app_get_fields -> app_get_layout -> builder_tool_contract (if shape is unclear) -> app_layout_apply`
74
+ `app_get_fields -> app_get_layout -> app_layout_apply`
77
75
  - Add workflow:
78
76
  `builder_tool_contract -> app_get_fields -> app_get_flow -> role_search/member_search -> app_flow_apply -> app_get_flow`
79
77
  - Update specific flow nodes:
80
78
  `app_get_flow -> app_flow_apply.patch_nodes[]`
81
79
  - Add views:
82
- `builder_tool_contract -> app_get_fields -> app_get_views -> app_views_apply.patch_views/upsert_views -> app_get_views`
83
- - Add QingBI charts:
84
- `builder_tool_contract -> app_get_fields -> app_get_charts -> app_charts_apply.patch_charts/upsert_charts -> app_get_charts`
80
+ `app_get_fields -> app_get_views -> app_views_apply.views[] -> app_get_views`
81
+ - Add QingBI charts for a portal:
82
+ `portal_apply.sections[].chart`
85
83
  - Show an existing QingBI chart inside a Qingflow app/view:
86
84
  `app_get -> app_associated_resources_apply.upsert_resources/patch_resources + view_configs -> app_get`
87
85
  - Create or update a portal:
@@ -72,6 +72,9 @@ class PublicExternalVisibilityMode(str, Enum):
72
72
 
73
73
  FIELD_TYPE_ALIASES: dict[str, PublicFieldType] = {
74
74
  "textarea": PublicFieldType.long_text,
75
+ "multiline": PublicFieldType.long_text,
76
+ "multi_line": PublicFieldType.long_text,
77
+ "multiline_text": PublicFieldType.long_text,
75
78
  "amount": PublicFieldType.amount,
76
79
  "currency": PublicFieldType.amount,
77
80
  "mobile": PublicFieldType.phone,
@@ -1456,6 +1459,9 @@ class CustomButtonViewButtonBindingPatch(StrictModel):
1456
1459
  raw_limits = payload.get("button_limit", payload.get("buttonLimit", payload.get("visible_when", payload.get("visibleWhen"))))
1457
1460
  if isinstance(raw_limits, list) and raw_limits and all(isinstance(item, dict) for item in raw_limits):
1458
1461
  payload["button_limit"] = [raw_limits]
1462
+ for alias in ("buttonLimit", "visible_when", "visibleWhen"):
1463
+ if alias in payload and alias != "button_limit":
1464
+ payload.pop(alias, None)
1459
1465
  return payload
1460
1466
 
1461
1467
 
@@ -1642,7 +1648,7 @@ class ViewButtonBindingPatch(StrictModel):
1642
1648
  being_main: bool = Field(default=False, validation_alias=AliasChoices("being_main", "beingMain"))
1643
1649
  button_limit: list[list[ViewFilterRulePatch]] = Field(
1644
1650
  default_factory=list,
1645
- validation_alias=AliasChoices("button_limit", "buttonLimit"),
1651
+ validation_alias=AliasChoices("button_limit", "buttonLimit", "visible_when", "visibleWhen"),
1646
1652
  )
1647
1653
  button_formula: str | None = Field(default=None, validation_alias=AliasChoices("button_formula", "buttonFormula"))
1648
1654
  button_formula_type: int = Field(default=1, validation_alias=AliasChoices("button_formula_type", "buttonFormulaType"))
@@ -1671,9 +1677,12 @@ class ViewButtonBindingPatch(StrictModel):
1671
1677
  payload["config_type"] = "DETAIL"
1672
1678
  elif normalized_config in {"inside", "list", "row", "row_action"}:
1673
1679
  payload["config_type"] = "INSIDE"
1674
- raw_limits = payload.get("button_limit", payload.get("buttonLimit"))
1680
+ raw_limits = payload.get("button_limit", payload.get("buttonLimit", payload.get("visible_when", payload.get("visibleWhen"))))
1675
1681
  if isinstance(raw_limits, list) and raw_limits and all(isinstance(item, dict) for item in raw_limits):
1676
1682
  payload["button_limit"] = [raw_limits]
1683
+ for alias in ("buttonLimit", "visible_when", "visibleWhen"):
1684
+ if alias in payload and alias != "button_limit":
1685
+ payload.pop(alias, None)
1677
1686
  return payload
1678
1687
 
1679
1688
  @model_validator(mode="after")
@@ -1763,68 +1772,63 @@ class ChartFilterRulePatch(StrictModel):
1763
1772
  return self
1764
1773
 
1765
1774
 
1766
- _CHART_METRIC_RE = re.compile(r"^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)\s*$")
1767
-
1768
-
1769
- def _normalize_chart_string_list(value: Any) -> list[str]:
1770
- if value is None:
1771
- return []
1772
- if isinstance(value, (list, tuple)):
1773
- return [str(item).strip() for item in value if item is not None and str(item).strip()]
1774
- raw = str(value).strip()
1775
- return [raw] if raw else []
1775
+ class ChartMetricPatch(StrictModel):
1776
+ op: str = "count"
1777
+ field_name: str | None = Field(default=None, validation_alias=AliasChoices("field", "field_name", "fieldName", "name"))
1778
+ alias: str | None = None
1776
1779
 
1780
+ @model_validator(mode="before")
1781
+ @classmethod
1782
+ def normalize_metric(cls, value: Any) -> Any:
1783
+ if isinstance(value, str):
1784
+ raw = value.strip()
1785
+ match = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*(.*?)\s*\)", raw)
1786
+ if match:
1787
+ op = match.group(1).strip().lower()
1788
+ field = match.group(2).strip()
1789
+ payload: dict[str, Any] = {"op": op}
1790
+ if field and field != "*":
1791
+ payload["field_name"] = field
1792
+ return payload
1793
+ if raw:
1794
+ return {"op": "sum", "field_name": raw}
1795
+ return {"op": "count"}
1796
+ if not isinstance(value, dict):
1797
+ return value
1798
+ payload = dict(value)
1799
+ if "operation" in payload and "op" not in payload:
1800
+ payload["op"] = payload.pop("operation")
1801
+ if "agg" in payload and "op" not in payload:
1802
+ payload["op"] = payload.pop("agg")
1803
+ if "aggregate" in payload and "op" not in payload:
1804
+ payload["op"] = payload.pop("aggregate")
1805
+ if "aggregation" in payload and "op" not in payload:
1806
+ payload["op"] = payload.pop("aggregation")
1807
+ return payload
1777
1808
 
1778
- def _parse_chart_metric_item(value: Any) -> tuple[list[str], str | None]:
1779
- if value is None:
1780
- return [], None
1781
- if isinstance(value, dict):
1782
- op = str(value.get("op") or value.get("agg") or value.get("aggregate") or value.get("aggregation") or "").strip().lower()
1783
- field = value.get("field", value.get("field_name", value.get("name", value.get("selector"))))
1784
- selectors = _normalize_chart_string_list(field)
1785
- return selectors, op or ("count" if not selectors else None)
1786
- raw = str(value).strip()
1787
- if not raw:
1788
- return [], None
1789
- matched = _CHART_METRIC_RE.match(raw)
1790
- if matched:
1791
- op = matched.group(1).strip().lower()
1792
- field = matched.group(2).strip()
1793
- if field in {"*", ""}:
1794
- return [], op
1795
- return [field], op
1796
- if raw.lower() in {"count", "count(*)"}:
1797
- return [], "count"
1798
- return [raw], None
1799
-
1800
-
1801
- def _apply_semantic_chart_metric_aliases(payload: dict[str, Any]) -> None:
1802
- raw_metrics: list[Any] = []
1803
- if "metric" in payload:
1804
- raw_metrics.append(payload.pop("metric"))
1805
- if "metrics" in payload:
1806
- metrics = payload.pop("metrics")
1807
- raw_metrics.extend(metrics if isinstance(metrics, list) else [metrics])
1808
- if not raw_metrics:
1809
- return
1810
- selectors: list[str] = []
1811
- aggregates: list[str] = []
1812
- for metric in raw_metrics:
1813
- metric_selectors, aggregate = _parse_chart_metric_item(metric)
1814
- selectors.extend(metric_selectors)
1815
- if aggregate:
1816
- aggregates.append(aggregate)
1817
- if selectors and "indicator_field_ids" not in payload:
1818
- payload["indicator_field_ids"] = selectors
1819
- if aggregates:
1820
- normalized_aggregate = aggregates[0]
1821
- config = payload.get("config")
1822
- if not isinstance(config, dict):
1823
- config = {}
1824
- else:
1825
- config = dict(config)
1826
- config.setdefault("aggregate", normalized_aggregate)
1827
- payload["config"] = config
1809
+ @model_validator(mode="after")
1810
+ def validate_metric(self) -> "ChartMetricPatch":
1811
+ normalized_op = str(self.op or "count").strip().lower()
1812
+ op_aliases = {
1813
+ "average": "avg",
1814
+ "mean": "avg",
1815
+ "total": "sum",
1816
+ "cnt": "count",
1817
+ "count_all": "count",
1818
+ }
1819
+ self.op = op_aliases.get(normalized_op, normalized_op)
1820
+ if self.field_name is not None:
1821
+ field_name = str(self.field_name).strip()
1822
+ self.field_name = field_name or None
1823
+ if self.alias is not None:
1824
+ alias = str(self.alias).strip()
1825
+ self.alias = alias or None
1826
+ supported = {"count", "sum", "avg", "max", "min"}
1827
+ if self.op not in supported:
1828
+ raise ValueError(f"chart metric op must be one of {sorted(supported)}")
1829
+ if self.op != "count" and not self.field_name:
1830
+ raise ValueError(f"chart metric op '{self.op}' requires field")
1831
+ return self
1828
1832
 
1829
1833
 
1830
1834
  class ChartUpsertPatch(StrictModel):
@@ -1834,6 +1838,17 @@ class ChartUpsertPatch(StrictModel):
1834
1838
  dimension_field_ids: list[str] = Field(default_factory=list)
1835
1839
  indicator_field_ids: list[str] = Field(default_factory=list)
1836
1840
  filters: list[ChartFilterRulePatch] = Field(default_factory=list)
1841
+ group_by: list[str] = Field(default_factory=list)
1842
+ rows: list[str] = Field(default_factory=list)
1843
+ columns: list[str] = Field(default_factory=list)
1844
+ metric: ChartMetricPatch | None = None
1845
+ metrics: list[ChartMetricPatch] = Field(default_factory=list)
1846
+ x_metric: ChartMetricPatch | None = None
1847
+ y_metric: ChartMetricPatch | None = None
1848
+ left_metric: ChartMetricPatch | None = None
1849
+ right_metric: ChartMetricPatch | None = None
1850
+ value_metric: ChartMetricPatch | None = None
1851
+ target_metric: ChartMetricPatch | None = None
1837
1852
  question_config: list[dict[str, Any]] = Field(default_factory=list)
1838
1853
  user_config: list[dict[str, Any]] = Field(default_factory=list)
1839
1854
  config: dict[str, Any] = Field(default_factory=dict)
@@ -1851,19 +1866,74 @@ class ChartUpsertPatch(StrictModel):
1851
1866
  payload["chart_type"] = payload.pop("type")
1852
1867
  if "dimension_fields" in payload and "dimension_field_ids" not in payload:
1853
1868
  payload["dimension_field_ids"] = payload.pop("dimension_fields")
1869
+ if "dimensions" in payload and "dimension_field_ids" not in payload and "group_by" not in payload:
1870
+ payload["group_by"] = payload.pop("dimensions")
1871
+ if "groupBy" in payload and "group_by" not in payload:
1872
+ payload["group_by"] = payload.pop("groupBy")
1873
+ if "where" in payload and "filters" not in payload:
1874
+ payload["filters"] = payload.pop("where")
1875
+ if "filter_rules" in payload and "filters" not in payload:
1876
+ payload["filters"] = payload.pop("filter_rules")
1877
+ if "filterRules" in payload and "filters" not in payload:
1878
+ payload["filters"] = payload.pop("filterRules")
1854
1879
  if "indicator_fields" in payload and "indicator_field_ids" not in payload:
1855
1880
  payload["indicator_field_ids"] = payload.pop("indicator_fields")
1856
1881
  if "metric_field_ids" in payload and "indicator_field_ids" not in payload:
1857
1882
  payload["indicator_field_ids"] = payload.pop("metric_field_ids")
1858
- if "group_by" in payload and "dimension_field_ids" not in payload:
1859
- payload["dimension_field_ids"] = _normalize_chart_string_list(payload.pop("group_by"))
1860
- if "groupBy" in payload and "dimension_field_ids" not in payload:
1861
- payload["dimension_field_ids"] = _normalize_chart_string_list(payload.pop("groupBy"))
1862
- if "where" in payload and "filters" not in payload:
1863
- payload["filters"] = payload.pop("where")
1864
- else:
1865
- payload.pop("where", None)
1866
- _apply_semantic_chart_metric_aliases(payload)
1883
+ metric_slots: list[Any] = []
1884
+ generic_metric_keys = ("metric", "metrics")
1885
+ axis_metric_keys = (
1886
+ "x_metric",
1887
+ "xMetric",
1888
+ "y_metric",
1889
+ "yMetric",
1890
+ "left_metric",
1891
+ "leftMetric",
1892
+ "right_metric",
1893
+ "rightMetric",
1894
+ "value_metric",
1895
+ "valueMetric",
1896
+ "target_metric",
1897
+ "targetMetric",
1898
+ )
1899
+
1900
+ def has_metric_value(key: str) -> bool:
1901
+ entry = payload.get(key)
1902
+ return entry is not None and entry != "" and entry != []
1903
+
1904
+ if any(has_metric_value(key) for key in generic_metric_keys) and any(has_metric_value(key) for key in axis_metric_keys):
1905
+ raise ValueError(
1906
+ "chart metric input is ambiguous: use either metric/metrics or axis-specific "
1907
+ "x_metric/y_metric, left_metric/right_metric, value_metric/target_metric, not both"
1908
+ )
1909
+ if "metric" in payload and "metrics" not in payload:
1910
+ payload["metrics"] = [payload["metric"]]
1911
+ for key in ("x_metric", "xMetric", "left_metric", "leftMetric", "value_metric", "valueMetric"):
1912
+ if key in payload:
1913
+ slot_value = payload.get(key)
1914
+ if slot_value is not None:
1915
+ metric_slots.append(slot_value)
1916
+ canonical = {
1917
+ "xMetric": "x_metric",
1918
+ "leftMetric": "left_metric",
1919
+ "valueMetric": "value_metric",
1920
+ }.get(key)
1921
+ if canonical and canonical not in payload:
1922
+ payload[canonical] = payload.pop(key)
1923
+ for key in ("y_metric", "yMetric", "right_metric", "rightMetric", "target_metric", "targetMetric"):
1924
+ if key in payload:
1925
+ slot_value = payload.get(key)
1926
+ if slot_value is not None:
1927
+ metric_slots.append(slot_value)
1928
+ canonical = {
1929
+ "yMetric": "y_metric",
1930
+ "rightMetric": "right_metric",
1931
+ "targetMetric": "target_metric",
1932
+ }.get(key)
1933
+ if canonical and canonical not in payload:
1934
+ payload[canonical] = payload.pop(key)
1935
+ if metric_slots and "metrics" not in payload:
1936
+ payload["metrics"] = metric_slots
1867
1937
  raw_type = payload.get("chart_type")
1868
1938
  if isinstance(raw_type, str):
1869
1939
  normalized = raw_type.strip().lower()
@@ -1890,8 +1960,31 @@ class ChartUpsertPatch(StrictModel):
1890
1960
  payload["dimension_field_ids"] = [str(item) for item in payload["dimension_field_ids"] if item is not None and str(item).strip()]
1891
1961
  if isinstance(payload.get("indicator_field_ids"), list):
1892
1962
  payload["indicator_field_ids"] = [str(item) for item in payload["indicator_field_ids"] if item is not None and str(item).strip()]
1963
+ for key in ("group_by", "rows", "columns"):
1964
+ if isinstance(payload.get(key), str):
1965
+ payload[key] = [payload[key]]
1966
+ if isinstance(payload.get(key), list):
1967
+ payload[key] = [str(item) for item in payload[key] if item is not None and str(item).strip()]
1893
1968
  return payload
1894
1969
 
1970
+ @model_validator(mode="after")
1971
+ def apply_semantic_chart_fields(self) -> "ChartUpsertPatch":
1972
+ if self.group_by and not self.dimension_field_ids:
1973
+ self.dimension_field_ids = list(self.group_by)
1974
+ if self.rows and not self.dimension_field_ids:
1975
+ self.dimension_field_ids = list(self.rows)
1976
+ semantic_metrics: list[ChartMetricPatch] = []
1977
+ if self.metrics:
1978
+ semantic_metrics.extend(self.metrics)
1979
+ for metric in (self.x_metric, self.y_metric, self.left_metric, self.right_metric, self.value_metric, self.target_metric):
1980
+ if metric is not None and metric not in semantic_metrics:
1981
+ semantic_metrics.append(metric)
1982
+ if semantic_metrics and not self.metrics:
1983
+ self.metrics = semantic_metrics
1984
+ if self.metrics and not self.metric:
1985
+ self.metric = self.metrics[0]
1986
+ return self
1987
+
1895
1988
 
1896
1989
  class ChartPartialPatch(StrictModel):
1897
1990
  chart_id: str | None = None
@@ -2287,6 +2380,9 @@ class ChartGetResponse(StrictModel):
2287
2380
  chart_id: str
2288
2381
  base: dict[str, Any] = Field(default_factory=dict)
2289
2382
  visibility: dict[str, Any] = Field(default_factory=dict)
2383
+ filters: list[list[dict[str, Any]]] = Field(default_factory=list)
2384
+ group_by: list[str] = Field(default_factory=list)
2385
+ metrics: list[dict[str, Any]] = Field(default_factory=list)
2290
2386
  config: dict[str, Any] = Field(default_factory=dict)
2291
2387
 
2292
2388