diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d0e47..1205993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,33 @@ Notable changes per release. Versions follow [semantic versioning](https://semver.org); while the major version is 0, minor bumps may include breaking changes and say so here. +## Unreleased + +### Upgrading from 0.6 + +- A spec that uses a field new here is refused by chartwright 0.6 (`extra_forbidden` or `literal_error`); upgrade CI before committing one. The new fields: + - a mixed chart's `kind: "scatter"`; + - a big number's `header_font_size` and `subtitle_font_size`, and a trendline's `header_font_size` and `trend_date_format`; + - an axis chart's `x_axis_title_margin`, `y_axis_title_margin` and `y_axis_title_position`; + - a markdown block's `id`; + - a chart's `cross_filter_scope`. +- A markdown link written `[words](tab:Title)` is a plain, dead link when chartwright 0.6 builds the spec: it doesn't know the form. +- `decompile` now reads a titled chart's stored title spacing back when it differs from the tool's own (`y_axis_title_position: "Left"`, Superset's default, for one), so `adopt` and `plan` keep it instead of resetting it to "Top". + +### Added + +- A mixed chart's query can draw points: `kind: "scatter"` writes Superset's `scatter` series type, a point per value and no line. Decompile reads a chart saved with it in Superset back as points instead of naming a loss. + +- A big number sets its text sizes: `header_font_size` (0.2, 0.3, 0.4, 0.5 or 0.6 of the card's height) and, on a plain big number, `subtitle_font_size` (0.125 to 0.4), the options Superset's panel offers. Unset, they stay at 0.4 and 0.15 as before. A trendline formats its dates with `trend_date_format`, e.g. `"%b %Y"`; its number stays a number. Decompile reads all three back instead of naming them, and `readability.kpi-text` judges a big number at its own sizes. + +- An axis chart sets its titles' spacing: `x_axis_title_margin`, `y_axis_title_margin` (pixels) and `y_axis_title_position` (`"Left"`, along the axis, or `"Top"`). Unset, the spacing chartwright writes stays as it was (30 px under the axis, 15 px above it). Each needs its title; a mixed chart's secondary title shares the y pair. + +- A markdown block takes an `id`, a lowercase slug unique in the layout: the block's component id becomes `MARKDOWN-`, which Superset renders as its DOM id, so the dashboard's `css` can style that one block (`#MARKDOWN-legend`). In rows, header and footer rows, and sketch legends; decompile reads it back. + +- A markdown link can open a tab of the same dashboard: `[definitions](tab:Definitions)`, or `tab:Sales/Trend` for a sub-tab, compiles to the dashboard's URL with the tab in its hash, which Superset opens on load. An unknown or ambiguous title fails validation and lists the tabs a link can name. Decompile turns such links back into `tab:` form, and `plan` compares two links by the tab they open. For a viewer signed in to Superset, the link loads the dashboard again on that tab, so filter selections not saved in the URL reset; a viewer Superset can't save filter state for stays on the current tab ([Limits](docs/LIMITS.md)). + +- A chart sets which charts its clicks cross-filter: `cross_filter_scope` is `"global"` (Superset's default), `"tab"` (the charts in its own tab or sub-tab), `"none"` (no chart, such as a summary table) or a list of chart names. It needs `dashboard.cross_filters`; big numbers, heatmaps, histograms and waterfalls emit no cross-filters and refuse it. `apply` and `restore` key the scopes to the live chart ids after the import. Decompile reads them back, so `plan` reports a scope changed in the UI, and `adopt` keeps a UI dashboard's per-chart scopes instead of listing them as a reset; a scope it can't carry is named as a loss. + ## 0.6.0 (2026-10-05) Bridge two totals with a waterfall and show a spread with a box plot, colour a KPI by its value, put cell bars on chosen table columns, and head the sections of a sketch. `advise` now checks axis titles, readable type, markdown that fits its block and sub-tabs too thin to earn a click, and `plan` is clean straight after `apply`. diff --git a/chartwright/adopt.py b/chartwright/adopt.py index c7ef273..53b9732 100644 --- a/chartwright/adopt.py +++ b/chartwright/adopt.py @@ -11,9 +11,10 @@ apply resets some of it to the spec's form. Adopt lists every such reset it can find (`resets`) and refuses while there are any, unless the caller accepts them (`accept_reset`): decompile's losses (settings and charts the spec can't hold, -which the first apply drops or takes off the dashboard) plus what decompile reads -past without a loss (per-chart cross-filter scopes, charts exempt from -auto-refresh, saved chart queries, tab and filter ids). +which the first apply drops or takes off the dashboard, a per-chart cross-filter +scope the spec can't carry among them) plus what decompile reads past without a +loss (the dashboard-wide cross-filter scope, charts exempt from auto-refresh, saved +chart queries, tab and filter ids). Adopt also refuses, each time saying what to do instead, when the result could not be applied safely: diff --git a/chartwright/apply.py b/chartwright/apply.py index 9570467..b808d7c 100644 --- a/chartwright/apply.py +++ b/chartwright/apply.py @@ -21,7 +21,7 @@ from . import ids from .client import SupersetClient, SupersetAPIError -from .compiler import compile_bundle +from .compiler import compile_bundle, cross_filter_configuration from .resolver import Resolution, resolve from .smoke import SmokeResult, smoke from .spec import DashboardSpec @@ -190,6 +190,79 @@ def _apply_filter_scopes(spec: DashboardSpec, client: SupersetClient, dashboard_ return [] +def _apply_cross_filter_scopes(spec: DashboardSpec, client: SupersetClient, dashboard_id: int) -> list[str]: + """Rewrite chart_configuration with the live chart ids. The bundle carries placeholder + ids, which only the 6.1.0 importer remaps, and a chart's live id is known only now.""" + if all(c.cross_filter_scope in (None, "global") for c in spec.charts): + return [] # the import replaced the metadata with the bundle's, which has none + detail = client.get(f"/api/v1/dashboard/{dashboard_id}")["result"] + config = cross_filter_configuration(spec, json.loads(detail.get("position_json") or "{}")) + return _put_chart_configuration(client, dashboard_id, detail, config) + + +def _put_chart_configuration(client: SupersetClient, dashboard_id: int, detail: dict, + config: dict) -> list[str]: + metadata = json.loads(detail.get("json_metadata") or "{}") + if metadata.get("chart_configuration") == config: + return [] + metadata["chart_configuration"] = config + r = client.put_json(f"/api/v1/dashboard/{dashboard_id}", {"json_metadata": json.dumps(metadata)}) + if r.status_code != 200: + return [f"cross-filter scope PUT failed: HTTP {r.status_code} {r.text[:300]}"] + return [] + + +def _chart_ids_by_uuid(position: dict) -> dict[str, int]: + return {str(n["meta"]["uuid"]): n["meta"]["chartId"] for n in position.values() + if isinstance(n, dict) and n.get("type") == "CHART" + and isinstance(n.get("meta"), dict) and n["meta"].get("uuid") and "chartId" in n["meta"]} + + +def remap_chart_configuration(config: dict, id_map: dict[int, int]) -> dict: + """chart_configuration with every chart id moved through `id_map` (old -> new): each + entry's key and id, its scope's excluded ids and its chartsInScope. An entry or id + with no new id is dropped. Superset 6.1.0's importer does the same, except + chartsInScope (commands/dashboard/importers/v1/utils.py:146-189).""" + def moved(ids) -> list: + return [id_map[i] for i in ids if isinstance(i, int) and i in id_map] if isinstance(ids, list) else ids + + out: dict = {} + for key, entry in config.items(): + new = id_map.get(int(key)) if str(key).isdigit() else None + if new is None or not isinstance(entry, dict): + continue + entry = {**copy.deepcopy(entry), "id": new} + cross = entry.get("crossFilters") + if isinstance(cross, dict): + if isinstance(cross.get("scope"), dict) and "excluded" in cross["scope"]: + cross["scope"]["excluded"] = moved(cross["scope"]["excluded"]) + if "chartsInScope" in cross: + cross["chartsInScope"] = moved(cross["chartsInScope"]) + out[str(new)] = entry + return out + + +def _restore_cross_filter_scopes(zip_bytes: bytes, client: SupersetClient, + dashboard_id: int) -> list[str]: + """Put the backup's own chart_configuration back under the chart ids the dashboard + has now, every entry kept as backed up: a chart decompile can't read keeps its scope, + and a scope keeps its roots. Ids map by chart uuid, from the backup's layout to the + live one (build_uuid_to_id_map, importers/v1/utils.py:48-71 at 6.1.0); 4.1.4 and + 5.0.0 import the ids unmapped.""" + zf = zipfile.ZipFile(io.BytesIO(zip_bytes)) + backup = next((yaml.safe_load(zf.read(n)) or {} for n in zf.namelist() + if "/dashboards/" in n and n.endswith(".yaml")), {}) + config = (backup.get("metadata") or {}).get("chart_configuration") + if not isinstance(config, dict) or not config: + return [] # the import wrote the backup's metadata, which has none + detail = client.get(f"/api/v1/dashboard/{dashboard_id}")["result"] + live = _chart_ids_by_uuid(json.loads(detail.get("position_json") or "{}")) + id_map = {old: live[u] for u, old in _chart_ids_by_uuid(backup.get("position") or {}).items() + if u in live and isinstance(old, int)} + return _put_chart_configuration(client, dashboard_id, detail, + remap_chart_configuration(config, id_map)) + + def _unlink_charts_off_the_layout(client: SupersetClient, dashboard_id: int) -> str | None: """Link the dashboard to exactly the charts in its layout, as saving it in Superset does: a json_metadata PUT that carries `positions` sets the dashboard's charts to @@ -521,14 +594,17 @@ def restore_bundle(zip_bytes: bytes, slug: str, client: SupersetClient) -> Apply from .spec import load_spec dec = decompile_bundle(zip_bytes, live_dataset_lookup(client)) + scope_errors = [] try: spec = load_spec(dec.spec) except Exception as e: # noqa: BLE001 - degraded bundles restore without scopes - report.warnings.append(f"scopes not reapplied (bundle spec not loadable: {e})") + report.warnings.append(f"filter scopes not reapplied (bundle spec not loadable: {e})") else: - scope_errors = _apply_filter_scopes(spec, client, report.dashboard_id) - for e in scope_errors: - report.warnings.append(f"scope reapply: {e}") + scope_errors += _apply_filter_scopes(spec, client, report.dashboard_id) + # Cross-filter scopes come from the backup itself, not its decompiled spec. + scope_errors += _restore_cross_filter_scopes(zip_bytes, client, report.dashboard_id) + for e in scope_errors: + report.warnings.append(f"scope reapply: {e}") if not_restored: # The layout is back but these charts still hold the newer params: not a # restore anyone should be told succeeded. @@ -767,6 +843,7 @@ def _auto_restore(reason: str) -> None: report.stage = "scope" scope_errors = _apply_filter_scopes(spec, client, report.dashboard_id) + scope_errors += _apply_cross_filter_scopes(spec, client, report.dashboard_id) if scope_errors: report.import_detail = "; ".join(scope_errors) return report diff --git a/chartwright/compiler.py b/chartwright/compiler.py index 431cd13..d47e34a 100644 --- a/chartwright/compiler.py +++ b/chartwright/compiler.py @@ -29,6 +29,7 @@ OPENING_KEY_GAP, OPENING_OTHER_KEY, PIVOT_ORDER, + TAB_LINK_RE, TICK_LAYOUTS, DashboardSpec, DividerBlock, @@ -266,15 +267,16 @@ def _adhoc_filter_list(filters) -> list[dict]: "value_asc": "value_desc", "value_desc": "value_asc"} -def _pin_big_number_fonts(p: dict) -> None: - """Pin the number and subtitle sizes to the plugin's own documented - defaults (proportions of the card). Left unset, 6.1 renders a subtitle fed +def _pin_big_number_fonts(p: dict, chart) -> None: + """Pin the number and subtitle sizes (proportions of the card): the chart's own, or + the plugin's documented defaults. Left unset, 6.1 renders a subtitle fed through the legacy `subheader` key at proportion 1 of the card (BigNumberTotal/transformProps.ts:77-80 at 6.1.0: `subheaderFontSize ?? 1`), so short subtitles blow up and crop. 4.1.4/5.0.0 declare both controls - natively; 6.1 honors them through that same fallback.""" - p["header_font_size"] = 0.4 - p["subheader_font_size"] = 0.15 + natively; 6.1 honors them through that same fallback. A trendline has no + subtitle size of its own: its comparison line keeps 0.15.""" + p["header_font_size"] = chart.header_font_size or 0.4 + p["subheader_font_size"] = getattr(chart, "subtitle_font_size", None) or 0.15 def _chart_params(chart, spec: DashboardSpec, resolution: Resolution) -> dict: @@ -307,7 +309,7 @@ def metric(m: str): # number of epoch milliseconds shows as a date too. p["time_format"] = chart.date_format p["force_timestamp_formatting"] = True - _pin_big_number_fonts(p) + _pin_big_number_fonts(p, chart) if chart.conditional_formatting: p["conditional_formatting"] = [ _big_number_rule_payload(r, chart) for r in chart.conditional_formatting @@ -337,7 +339,12 @@ def metric(m: str): else chart.rolling_min_periods) if chart.number_format: p["y_axis_format"] = chart.number_format - _pin_big_number_fonts(p) + if chart.trend_date_format: + # The trendline's dates (BigNumberWithTrendline/controlPanel.tsx:212 and + # transformProps.ts:245-249, :275 at 6.1.0). Never forced: forced, Superset would + # print the number itself as a date (transformProps.ts:109-114). + p["time_format"] = chart.trend_date_format + _pin_big_number_fonts(p, chart) # BigNumberWithTrendline controlPanel.tsx, all three releases; `subtitle` 6.0.0 or later. if chart.compare_lag is not None: p["compare_lag"] = chart.compare_lag @@ -509,7 +516,7 @@ def metric(m: str): _y_axis_params(chart, p) if chart.y_axis_title_secondary: p["yAxisTitleSecondary"] = chart.y_axis_title_secondary - _y_title_layout(p) + _y_title_layout(p, chart) # one margin and position for both value axes if chart.y_axis_min_secondary is not None or chart.y_axis_max_secondary is not None: p["y_axis_bounds_secondary"] = [chart.y_axis_min_secondary, chart.y_axis_max_secondary] if chart.y_axis_log_secondary: @@ -779,11 +786,14 @@ def _series_limit_params(series, p: dict, suffix: str, metric) -> None: p[f"order_desc{suffix}"] = False -def _y_title_layout(p: dict) -> None: +def _y_title_layout(p: dict, chart=None, auto: dict | None = None) -> None: # Title above the axis, 15 px clear of it: Superset's own default for a bar's y - # title at 6.1.0. Without a margin, 6.1.0 reserves no room for the title. - p["y_axis_title_margin"] = 15 - p["y_axis_title_position"] = "Top" + # title at 6.1.0. Without a margin, 6.1.0 reserves no room for the title. An axis + # chart's own y_axis_title_margin / y_axis_title_position win. + auto = auto or axis_title_defaults(horizontal=False, rotated=False) + for key in ("y_axis_title_margin", "y_axis_title_position"): + own = getattr(chart, key, None) + p[key] = auto[key] if own is None else own # A horizontal bar's titles. transformProps lays them out for a vertical chart, then swaps the @@ -797,24 +807,31 @@ def _y_title_layout(p: dict) -> None: HBAR_VALUE_TITLE_GAP = 30 # clears the value labels under the axis +def axis_title_defaults(horizontal: bool, rotated: bool) -> dict: + """The title spacing written for an axis chart that sets none of its own. The x title + clears the tick labels (rotated labels hang lower; 0, 6.1.0's default margin, draws + the title over them); a horizontal bar's titles are laid out as above. Decompile reads + a stored value that differs back as the chart's own.""" + if horizontal: + return {"x_axis_title_margin": HBAR_CATEGORY_TITLE_GAP, + "y_axis_title_margin": HBAR_VALUE_TITLE_GAP, "y_axis_title_position": "Left"} + return {"x_axis_title_margin": 50 if rotated else 30, + "y_axis_title_margin": 15, "y_axis_title_position": "Top"} + + def _y_axis_params(chart, p: dict) -> None: """Axis titles, bounds, truncation and log scale: titleControls (sections/chartTitle.tsx) and the panels' Y Axis section, at 4.1.4, 5.0.0 and 6.1.0. transformProps passes y_axis_bounds to ECharts as the axis min and max, so a bound applies on every release.""" - horizontal = getattr(chart, "orientation", None) == "horizontal" + auto = axis_title_defaults(getattr(chart, "orientation", None) == "horizontal", + bool(chart.x_label_rotation)) if chart.x_axis_title: p["x_axis_title"] = chart.x_axis_title - # Clear of the tick labels; rotated labels hang lower. 0 (6.1.0's default margin) - # draws the title over the labels. - p["x_axis_title_margin"] = (HBAR_CATEGORY_TITLE_GAP if horizontal - else 50 if chart.x_label_rotation else 30) + p["x_axis_title_margin"] = (auto["x_axis_title_margin"] if chart.x_axis_title_margin is None + else chart.x_axis_title_margin) if chart.y_axis_title: p["y_axis_title"] = chart.y_axis_title - if horizontal: - p["y_axis_title_margin"] = HBAR_VALUE_TITLE_GAP - p["y_axis_title_position"] = "Left" - else: - _y_title_layout(p) + _y_title_layout(p, chart, auto) if chart.y_axis_min is not None or chart.y_axis_max is not None: p["y_axis_bounds"] = [chart.y_axis_min, chart.y_axis_max] if chart.y_axis_truncate: @@ -1101,7 +1118,9 @@ def _x_label_params(chart: _AxisChart, p: dict) -> None: # Chart types whose 6.1.0 control panel declares echart_options (scatter's does not). _ECHART_OPTIONS_TYPES = ("timeseries_line", "timeseries_bar", "timeseries_area", "mixed") # A mixed query's kind -> its seriesType (EchartsTimeseriesSeriesType, Timeseries/types.ts). -SERIES_TYPE = {"bar": "bar", "line": "line", "area": "line"} +# seriesType choices (MixedTimeseries/controlPanel.tsx; EchartsTimeseriesSeriesType in +# Timeseries/types.ts, the same enum at 4.1.4, 5.0.0 and 6.1.0). +SERIES_TYPE = {"bar": "bar", "line": "line", "area": "line", "scatter": "scatter"} def _has_bars(chart) -> bool: @@ -1171,7 +1190,9 @@ def _rows_into(pos: dict, rows, spec: DashboardSpec, parents: list[str], prefix: child_ids: list[str] = [] for j, item in enumerate(row): if isinstance(item, MarkdownBlock): - md_id = f"MARKDOWN-{prefix}{i + 1}-{j + 1}" + # The component id is the block's DOM id (Markdown/Markdown.tsx:418 at 6.1.0, + # Markdown.jsx:363 at 4.1.4 / 5.0.0), so a named block is a CSS target. + md_id = f"MARKDOWN-{item.id or f'{prefix}{i + 1}-{j + 1}'}" child_ids.append(md_id) pos[md_id] = { "type": "MARKDOWN", @@ -1179,7 +1200,7 @@ def _rows_into(pos: dict, rows, spec: DashboardSpec, parents: list[str], prefix: "children": [], "parents": [*parents, row_id], "meta": { - "code": item.markdown, + "code": link_tabs(spec, item.markdown), "width": spec.resolved_item_width(item), "height": grid_rows(item.height or 4), }, @@ -1229,7 +1250,7 @@ def _header_meta(entry: HeaderBlock) -> dict: "background": BACKGROUND[entry.background]} -def _sketch_block_node(pos, holder, sb, width, node_id, parents) -> str: +def _sketch_block_node(pos, spec, holder, sb, width, node_id, parents) -> str: """Emit one MARKDOWN or HEADER node from a sketch block; a markdown block's own height wins over the drawn one, as a chart's does.""" entry = holder.sketch_block(sb) @@ -1237,9 +1258,9 @@ def _sketch_block_node(pos, holder, sb, width, node_id, parents) -> str: meta = _header_meta(entry) node_id = f"HEADER-{node_id}" else: - meta = {"code": entry.markdown, "width": width, + meta = {"code": link_tabs(spec, entry.markdown), "width": width, "height": int(round(holder.sketch_block_height(sb) * ROW_UNITS_PER_SPEC_UNIT))} - node_id = f"MARKDOWN-{node_id}" + node_id = f"MARKDOWN-{entry.id or node_id}" # a named block is a CSS target, as in rows pos[node_id] = {"type": "HEADER" if sb.kind == "header" else "MARKDOWN", "id": node_id, "children": [], "parents": parents, "meta": meta} return node_id @@ -1272,7 +1293,7 @@ def _sketch_into(pos, holder, spec, parents: list[str], prefix: str, counter: li items = child.children if isinstance(child, SketchColumn) else [child] # A list of its own per node: a shared one is a YAML alias in the bundle. col_children = [ - _sketch_block_node(pos, holder, sc, child.width, f"{slot}-{k + 1}", + _sketch_block_node(pos, spec, holder, sc, child.width, f"{slot}-{k + 1}", [*parents, row_id, col_id]) if isinstance(sc, SketchBlock) else _sketch_chart_node(pos, spec, sc, child.width, [*parents, row_id, col_id], counter) @@ -1288,7 +1309,7 @@ def _sketch_into(pos, holder, spec, parents: list[str], prefix: str, counter: li child_ids.append(col_id) elif isinstance(child, SketchBlock): child_ids.append( - _sketch_block_node(pos, holder, child, child.width, slot, [*parents, row_id])) + _sketch_block_node(pos, spec, holder, child, child.width, slot, [*parents, row_id])) else: child_ids.append( _sketch_chart_node(pos, spec, child, child.width, [*parents, row_id], counter) @@ -1304,6 +1325,24 @@ def _sketch_into(pos, holder, spec, parents: list[str], prefix: str, counter: li return row_ids +def tab_component_id(position: tuple[int, ...]) -> str: + """The id _position gives the tab at `position` (Layout.tabs_titled): TAB-sdc-[-].""" + return "TAB-sdc-" + "-".join(str(i + 1) for i in position) + + +def link_tabs(spec: DashboardSpec, text: str) -> str: + """[words](tab:Title) -> [words](/superset/dashboard//#). Superset opens + the tab named in the URL hash on load: hydrate turns the hash into directPathToChild + (dashboard/actions/hydrate.ts:287-294 at 6.1.0, hydrate.js:222-226 at 4.1.4 / 5.0.0) + and each Tabs component starts on the child on that path (Tabs.tsx:135-140 at 6.1.0, + Tabs.jsx:190-193 / :139-142). A same-page hash change does not, hence the full URL.""" + def url(m) -> str: + (position,) = spec.layout.tabs_titled(m.group(1)) # validated: exactly one + return f"](/superset/dashboard/{spec.dashboard.slug}/#{tab_component_id(position)})" + + return TAB_LINK_RE.sub(url, text) + + def _position(spec: DashboardSpec) -> dict: """The layout tree Superset's UI builds by drag and drop, generated from the spec instead.""" @@ -1334,7 +1373,7 @@ def content_into(tab, parents: list[str], prefix: str) -> list[str]: return into(pos, tab if tab.sketch else tab.rows, spec, parents, prefix, counter) for k, tab in enumerate(spec.layout.tabs or []): - tab_id = f"TAB-sdc-{k + 1}" + tab_id = tab_component_id((k,)) tab_parents = ["ROOT_ID", "GRID_ID", tabs_id] if tab.tabs: # sub-tabs: a TABS node inside this TAB (its own id space, so it can @@ -1343,7 +1382,7 @@ def content_into(tab, parents: list[str], prefix: str) -> list[str]: sub_parents = [*tab_parents, tab_id, sub_tabs_id] sub_ids = [] for j, sub in enumerate(tab.tabs): - sub_id = f"TAB-sdc-{k + 1}-{j + 1}" + sub_id = tab_component_id((k, j)) row_ids = content_into(sub, [*sub_parents, sub_id], f"sdc-t{k + 1}-{j + 1}-") pos[sub_id] = tab_node(sub_id, sub.title, row_ids, sub_parents) sub_ids.append(sub_id) @@ -1544,8 +1583,46 @@ def _range_default_mask(f) -> dict | None: } +def cross_filter_configuration(spec: DashboardSpec, position: dict) -> dict: + """json_metadata.chart_configuration for the charts whose cross-filter scope is not + the dashboard's (ChartConfiguration, dashboard/types.ts:87-106 at 6.1.0), keyed by the + chart ids in `position`: the bundle's placeholders, which the 6.1.0 importer remaps + (commands/dashboard/importers/v1/utils.py:147-190; 4.1.4 and 5.0.0 do not), or the + live ids apply writes after import. Charts are found by the spec's uuids. A chart's + own id is in its excluded list, as the scoping modal saves it (ScopingModal.tsx:240-246).""" + nodes = {(n.get("meta") or {}).get("uuid"): n for n in position.values() + if isinstance(n, dict) and n.get("type") == "CHART"} + node_of = {c.name: nodes[u] for c in spec.charts + if (u := str(spec.chart_uuid(c.name))) in nodes} + id_of = {name: node["meta"]["chartId"] for name, node in node_of.items()} + out: dict = {} + for c in spec.charts: + scope = c.cross_filter_scope + if scope in (None, "global") or c.name not in id_of: + continue + me = id_of[c.name] + if scope == "none": + # What the scoping tree saves with nothing ticked (FilterScope/utils.ts:265-270): + # no root reaches no chart, one added later included (getChartIdsInFilterScope.ts:75-85). + scope_json, in_scope = {"rootPath": [], "excluded": []}, [] + elif scope == "tab": + tabs = [p for p in node_of[c.name].get("parents") or [] + if (position.get(p) or {}).get("type") == "TAB"] + if not tabs: + continue # moved out of its tab live; plan reports the difference + scope_json = {"rootPath": [tabs[-1]], "excluded": [me]} + in_scope = [i for n, i in id_of.items() + if n != c.name and tabs[-1] in (node_of[n].get("parents") or [])] + else: + in_scope = [id_of[n] for n in scope if n in id_of] + scope_json = {"rootPath": ["ROOT_ID"], "excluded": sorted(set(id_of.values()) - set(in_scope))} + out[str(me)] = {"id": me, "crossFilters": {"scope": scope_json, "chartsInScope": sorted(in_scope)}} + return out + + def _dashboard_yaml(spec: DashboardSpec, resolution: Resolution) -> dict: d = spec.dashboard + position = _position(spec) metadata: dict = { "color_scheme": d.color_scheme or "", "cross_filters_enabled": d.cross_filters, @@ -1564,6 +1641,9 @@ def _dashboard_yaml(spec: DashboardSpec, resolution: Resolution) -> dict: metadata["show_chart_timestamps"] = True if spec.filters: metadata["native_filter_configuration"] = _native_filters(spec, resolution) + chart_configuration = cross_filter_configuration(spec, position) + if chart_configuration: + metadata["chart_configuration"] = chart_configuration out = { "dashboard_title": d.title, "description": d.description, @@ -1574,7 +1654,7 @@ def _dashboard_yaml(spec: DashboardSpec, resolution: Resolution) -> dict: "certification_details": d.certification_details, "published": d.published, "uuid": str(spec.dashboard_uuid()), - "position": _position(spec), + "position": position, "metadata": metadata, "version": "1.0.0", } diff --git a/chartwright/dashdiff.py b/chartwright/dashdiff.py index 4a4cf3c..6b5e7ec 100644 --- a/chartwright/dashdiff.py +++ b/chartwright/dashdiff.py @@ -14,7 +14,8 @@ import yaml from .client import SupersetClient -from .compiler import _metric_payload, compile_bundle, filter_id, grid_rows, spec_units +from .compiler import (_metric_payload, axis_title_defaults, compile_bundle, filter_id, grid_rows, + link_tabs, spec_units) from .decompile import _metric_to_spec, decompile_live from .spec import ( BAR_SWITCHES, DATASET_FILTER_TYPES, DEFAULT_ROW_LIMIT, DEFAULT_TIME_GRAIN, FORMAT_COLOR_HEX, @@ -190,6 +191,15 @@ def _normalize(spec: DashboardSpec) -> dict: for field in NAMED_COLOUR_FIELDS: if chart.get(field): chart[field] = named_hex(chart[field]) + if isinstance(chart.get("cross_filter_scope"), list): + chart["cross_filter_scope"] = sorted(chart["cross_filter_scope"]) # a set of chart names + if "x_label_rotation" in type(model).model_fields: + # Title spacing compile writes anyway reads back as unset (decompile). + auto = axis_title_defaults(chart.get("orientation") == "horizontal", + bool(chart.get("x_label_rotation"))) + for key, value in auto.items(): + if chart.get(key) == value: + del chart[key] if chart["type"] == "table": # An empty list compiles as the omitted one, which is how decompile reads it. for key in ("metrics", "groupby"): @@ -218,6 +228,8 @@ def norm_rows(model_rows, data_rows) -> None: if not isinstance(mitem, str): ditem["width"] = spec.resolved_item_width(mitem) ditem.setdefault("height", 4) + # tab links compare by the tab they open: tab:Trend == tab:Sales/Trend + ditem["markdown"] = link_tabs(spec, ditem["markdown"]) def sketch_as_rows(holder) -> list: """Sketch -> rows of chart names, a stacked COLUMN as a nested list, so a @@ -234,8 +246,9 @@ def item(sc, width: int): entry = holder.sketch_block(sc) if sc.kind == "header": return {**entry.model_dump(), "width": width} - return {"markdown": entry.markdown, "width": width, - "height": holder.sketch_block_height(sc)} + block = {"markdown": link_tabs(spec, entry.markdown), "width": width, + "height": holder.sketch_block_height(sc)} + return {**block, "id": entry.id} if entry.id else block rows = [] for srow in holder.parsed_sketch(): @@ -324,12 +337,10 @@ def adopted_metadata_resets(live_meta: dict) -> list[tuple[str, str]]: """Dashboard json_metadata keys a dashboard built in the UI may set and the import replaces with the compiled metadata, which the spec can't express: (key, what the first apply does to it). The importer writes json_metadata whole (docs/CONTRACTS.md, - "How dashboard settings are stored").""" + "How dashboard settings are stored"). Per-chart cross-filter scopes are no longer + among them: decompile reads them into each chart's cross_filter_scope, and names a + scope it can't carry as a loss.""" out = [] - if live_meta.get("chart_configuration"): - out.append(("chart_configuration", - "per-chart cross-filter scopes are cleared; cross-filters, when on, reach " - "every chart")) gcc = live_meta.get("global_chart_configuration") or {} scope = gcc.get("scope") or {} if scope.get("excluded") or (scope.get("rootPath") or ["ROOT_ID"]) != ["ROOT_ID"]: @@ -423,11 +434,16 @@ def plan(target: DashboardSpec, client: SupersetClient, superset_version: str | # apply matches adopted charts by uuid, so a chart renamed in the spec is the # same chart under a new title (a change), not one removed and one added. spec_name = {str(target.chart_uuid(c.name)): c.name for c in target.charts} + renamed: dict[str, str] = {} for live_name, u in live_result.chart_uuids.items(): new = spec_name.get(u) if new and new != live_name and live_name in l_charts and new not in l_charts: l_charts[new] = l_charts.pop(live_name) live_result.dataset_uuids[new] = live_result.dataset_uuids.get(live_name) + renamed[live_name] = new + for chart in l_charts.values(): # a scope names its charts, under their new names too + if renamed and isinstance(chart.get("cross_filter_scope"), list): + chart["cross_filter_scope"] = sorted(renamed.get(n, n) for n in chart["cross_filter_scope"]) # Dataset identity compares by resolved uuid, not by literal triple: # an omitted schema in the spec means "unambiguous", not "different". for chart in target.charts: diff --git a/chartwright/decompile.py b/chartwright/decompile.py index 84d59d9..e54cd3c 100644 --- a/chartwright/decompile.py +++ b/chartwright/decompile.py @@ -21,14 +21,15 @@ BACKGROUND, BAR_SWITCH_KEYS, COLUMN_CONFIG_KEYS, CONTRIBUTION_VALUES, FOOTER_PREFIX, FORMAT_OPERATOR, HEADER_PREFIX, HEADER_SIZE, HEATMAP_X_SORT, HEATMAP_Y_SORT, LEGEND_TYPES, METRIC_OPTION_PREFIX, ROW_UNITS_PER_SPEC_UNIT, SDC_BAR_MARKER, STACK_VALUES, VIZ_TYPE, - WHISKER_OPTIONS, bridge_totals_sql, parse_steps_order_sql, spec_units, + WHISKER_OPTIONS, axis_title_defaults, bridge_totals_sql, parse_steps_order_sql, spec_units, ) from .spec import ( ADHOC_AGGREGATES, BRIDGE_TOTAL, DEPENDENCY_PARENT_TYPES, FORMAT_COLOR_HEX, FORMAT_RANGE_OPERATORS, FORMAT_TEXT_HEX, FUNNEL_LABEL_TYPES, HEATMAP_DEFAULT_SCHEME, - HEX_COLOUR_RE, PIVOT_ORDER, SUPERSET_DEFAULTS, TICK_LAYOUTS, TREND_DEFAULT_HEX, - WATERFALL_DEFAULT_HEX, FilterOp, LabelType, PivotAggregate, SequentialScheme, metric_label, - parse_metric, row_items, + HEX_COLOUR_RE, MARKDOWN_ID_PATTERN, NO_CROSS_FILTER_TYPES, PIVOT_ORDER, SUPERSET_DEFAULTS, + TAB_LINK_RE, TICK_LAYOUTS, TREND_DEFAULT_HEX, + WATERFALL_DEFAULT_HEX, FilterOp, HeaderFontSize, LabelType, PivotAggregate, SequentialScheme, + SubtitleFontSize, metric_label, parse_metric, row_items, ) REVERSE_VIZ = {v: k for k, v in VIZ_TYPE.items() if k != "bar"} # echarts_timeseries_bar -> timeseries_bar @@ -294,7 +295,35 @@ def _axis_to_spec(p: dict, out: dict, losses: list, name: str, spec_type: str) - _set_bounds(p, "y_axis_bounds_secondary", out, "_secondary", bool(p.get("logAxisSecondary")), losses, name) read |= {"yAxisTitleSecondary", "logAxisSecondary", "y_axis_bounds_secondary"} - return read + return read | _title_spacing_to_spec(p, out, losses, name) + + +def _title_spacing_to_spec(p: dict, out: dict, losses: list, name: str) -> set[str]: + """A title's margin and position, stored beside the title, back as the chart's own when + they differ from what compile writes for it unset (axis_title_defaults). Without its + title they do nothing, so they read as nothing (Timeseries/transformProps.ts:737-740). + One stored empty beside its title has no spec form, and apply writes compile's value.""" + auto = axis_title_defaults(out.get("orientation") == "horizontal", bool(out.get("x_label_rotation"))) + y_titled = "y_axis_title" in out or "y_axis_title_secondary" in out + for key, titled in (("x_axis_title_margin", "x_axis_title" in out), + ("y_axis_title_margin", y_titled), ("y_axis_title_position", y_titled)): + value = p.get(key) + if titled and key in p and value in (None, ""): + losses.append(Loss(name, f"{key} {value!r} not preserved ({auto[key]!r} on re-apply)")) + continue + if not titled or value is None or value == auto[key]: + continue + if key == "y_axis_title_position": + ok = value in ("Left", "Top") + else: + value = _number(value) + ok = isinstance(value, int) and value >= 0 + if ok: + if value != auto[key]: + out[key] = value + else: + losses.append(Loss(name, f"{key} {p[key]!r} not representable; dropped")) + return {"x_axis_title_margin", "y_axis_title_margin", "y_axis_title_position"} def _legend_to_spec(p: dict, out: dict, spec_type: str) -> set[str]: @@ -674,6 +703,23 @@ def _format_to_spec(cf: dict) -> dict | None: return None if targets is None else rule | targets +def _big_number_sizes_to_spec(p: dict, out: dict, losses: list, name: str, spec_type: str) -> None: + """header_font_size (and a total's subheader_font_size, its subtitle_font_size) back as + the spec's sizes; the panel's default reads as unset, and a size it doesn't offer + is named. A trendline's subheader size sizes its comparison, which the spec pins.""" + sizes = [("header_font_size", "header_font_size", HeaderFontSize)] + if spec_type == "big_number_total": + sizes.append(("subheader_font_size", "subtitle_font_size", SubtitleFontSize)) + for key, field_name, options in sizes: + size = p.get(key) + if size is None or size == SUPERSET_DEFAULTS[spec_type][field_name]: + continue + if size in get_args(options): + out[field_name] = size + else: + losses.append(Loss(name, f"{key} {size!r} is not one of Superset's sizes; dropped")) + + def _big_number_rules_to_spec(p: dict, losses: list, name: str) -> list[dict]: """A big number's conditional_formatting as BigNumberFormatRule dicts. The plugin paints the number from every rule whose column is set, whichever column it names, @@ -888,6 +934,7 @@ def keep_row_limit() -> None: f"preserved; set date_format to keep it")) if p.get("y_axis_format") not in (None, "", "SMART_NUMBER") and "date_format" not in out: out["number_format"] = p["y_axis_format"] + _big_number_sizes_to_spec(p, out, losses, name, spec_type) rules = _big_number_rules_to_spec(p, losses, name) if rules: out["conditional_formatting"] = rules @@ -903,6 +950,9 @@ def keep_row_limit() -> None: out["time_grain"] = p["time_grain_sqla"] if p.get("y_axis_format") not in (None, "", "SMART_NUMBER"): out["number_format"] = p["y_axis_format"] + if isinstance(p.get("time_format"), str) and p["time_format"] not in ("", "smart_date"): + out["trend_date_format"] = p["time_format"] + _big_number_sizes_to_spec(p, out, losses, name, spec_type) lag = _number(p.get("compare_lag")) if isinstance(lag, int) and lag >= 1: out["compare_lag"] = lag @@ -1248,8 +1298,8 @@ def keep_row_limit() -> None: # transformSeries draws 'bar' as bars, and scatter, smooth and the steps their # own way; any other value, the old echarts_timeseries_* names included, as a # straight line (Timeseries/transformers.ts:237-243 at 4.1.4, :306-312 at 6.1.0). - kind = "bar" if stored == "bar" else "line" - if stored in ("scatter", "smooth", "start", "middle", "end"): + kind = stored if stored in ("bar", "scatter") else "line" + if stored in ("smooth", "start", "middle", "end"): losses.append(Loss(name, f"query {key.upper()} series type {stored!r} not " f"preserved (a straight line on re-apply)")) disp = kind_key.replace("seriesType", "") # "" for query A, "B" for query B @@ -1323,10 +1373,12 @@ def keep_row_limit() -> None: # a Big Number the chart was switched from colour nothing. mapped_here = mapped_here | {"compare_lag", "compare_suffix", "subtitle", "color_picker", "rolling_type", "rolling_periods", "min_periods", - "start_y_axis_at_zero", "conditional_formatting"} + "start_y_axis_at_zero", "conditional_formatting", + "time_format", "header_font_size"} if spec_type == "big_number_total": mapped_here = mapped_here | {"subtitle", "time_format", "force_timestamp_formatting", - "conditional_formatting"} + "conditional_formatting", "header_font_size", + "subheader_font_size"} if spec_type == "table": mapped_here = mapped_here | {"page_length", "show_totals", "include_search", "color_pn", "align_pn"} @@ -1617,6 +1669,16 @@ def _header_to_spec(meta: dict) -> dict | None: return header +def _markdown_id(component_id: str | None) -> str | None: + """A named block's id from its component id (compile writes MARKDOWN-). The tool's + unnamed blocks are MARKDOWN-sdc-..., and the UI mints MARKDOWN-, mixed case + and '_' (dashboard/util/newComponentFactory.ts:74 at 6.1.0), so neither reads as a name.""" + m = re.fullmatch(r"MARKDOWN-(.+)", component_id or "") + if m and re.fullmatch(MARKDOWN_ID_PATTERN, m.group(1)) and not m.group(1).startswith("sdc-"): + return m.group(1) + return None + + def _markdown_height(meta: dict) -> int | float | None: """A MARKDOWN node's height in spec units: exact, since text blocks take fifths.""" if not meta.get("height"): @@ -1654,6 +1716,8 @@ def handle(children_ids: list[str], depth: int = 0) -> None: losses.append(Loss("layout", f"chart {nm!r} in layout but not decompilable; removed from row")) elif ch.get("type") == "MARKDOWN": block: dict = {"markdown": meta.get("code") or ""} + if mid := _markdown_id(ch_id): + block["id"] = mid if meta.get("width"): block["width"] = max(1, min(12, int(meta["width"]))) if _markdown_height(meta) is not None: @@ -1756,6 +1820,120 @@ def _leaf_tabs(layout: dict) -> list[dict]: return [leaf for tab in layout.get("tabs") or [] for leaf in (tab.get("tabs") or [tab])] +def _kept_tabs(layout: dict, position: dict) -> dict[str, str]: + """Live TAB id -> its title in the decompiled layout ('Parent/Child' for a sub-tab), for + the tabs decompile kept as tabs; a flattened tab has none.""" + titles = {t["title"]: {s["title"] for s in t.get("tabs") or []} for t in layout.get("tabs") or []} + kept: dict[str, str] = {} + for tab_id, node in position.items(): + if not isinstance(node, dict) or node.get("type") != "TAB": + continue + title = (node.get("meta") or {}).get("text") + outer = [((position.get(p) or {}).get("meta") or {}).get("text") for p in node.get("parents") or [] + if (position.get(p) or {}).get("type") == "TAB"] + if not outer and title in titles: + kept[tab_id] = title + elif len(outer) == 1 and title in titles.get(outer[0], ()): + kept[tab_id] = f"{outer[0]}/{title}" + return kept + + +def _cross_filter_scope_of(entry) -> object: + """A chart_configuration entry's scope: "global" when it sets none, None when the + entry isn't shaped as Superset saves it (ChartConfiguration, dashboard/types.ts).""" + if entry is None or entry == {}: + return "global" + cross = entry.get("crossFilters") if isinstance(entry, dict) else None + if isinstance(entry, dict) and cross is None: + return "global" + return (cross.get("scope") or "global") if isinstance(cross, dict) else None + + +def _cross_filter_scopes_to_spec(metadata: dict, position: dict, layout: dict, charts_by_name: dict, + chart_uuids: dict[str, str], losses: list) -> None: + """chart_configuration (keyed by chart id) -> each chart's cross_filter_scope, by name + through the layout's chart ids (a node's uuid, else its sliceName). A chart's own id + never counts as in scope; a scope that reaches no chart reads as "none". A scope + rooted at a tab decompile flattened reads as the charts it reached, since "tab" needs + the chart in a tab. Any entry that can't be carried is named as a loss.""" + raw = metadata.get("chart_configuration") or {} + if not isinstance(raw, dict): + losses.append(Loss("dashboard", f"cross-filter scopes not preserved: not representable: {raw!r}")) + return + config = {k: v for k, v in raw.items() if _cross_filter_scope_of(v) != "global"} + if not config: + return + if not metadata.get("cross_filters_enabled", False): + losses.append(Loss("dashboard", "cross-filter scopes not preserved: cross-filtering is off")) + return + name_by_uuid = {u: n for n, u in chart_uuids.items()} + nodes = [n for n in position.values() if isinstance(n, dict) and n.get("type") == "CHART"] + name_of = {(n.get("meta") or {}).get("chartId"): + name_by_uuid.get(str((n.get("meta") or {}).get("uuid"))) or (n.get("meta") or {}).get("sliceName") + for n in nodes} + parents_of = {(n.get("meta") or {}).get("chartId"): n.get("parents") or [] for n in nodes} + kept_tabs = _kept_tabs(layout, position) + for key, entry in config.items(): + me = int(key) if str(key).isdigit() else None + if me not in name_of: + continue # a dead id: no chart on the layout, so the entry does nothing + chart = charts_by_name.get(name_of[me]) + if chart is None: + losses.append(Loss("dashboard", f"cross-filter scope of chart {name_of[me]!r} (id {me}) " + f"not preserved: no decompiled chart matches it")) + continue + scope = _cross_filter_scope_of(entry) + root = scope.get("rootPath") if isinstance(scope, dict) else None + excluded = (scope.get("excluded") or []) if isinstance(scope, dict) else None + if (chart["type"] in NO_CROSS_FILTER_TYPES or not isinstance(root, list) + or not isinstance(excluded, list) + or not all(isinstance(x, str) for x in root) + or not all(isinstance(x, (int, str)) for x in excluded)): + losses.append(Loss(chart["name"], "cross-filter scope not preserved: " + f"{entry if scope is None else scope}")) + continue + excluded = set(excluded) - {me} + tabs = [p for p in parents_of[me] if (position.get(p) or {}).get("type") == "TAB"] + if tabs and root == [tabs[-1]] and not excluded and tabs[-1] in kept_tabs: + chart["cross_filter_scope"] = "tab" + continue + in_scope = sorted(name for cid, name in name_of.items() + if cid != me and cid not in excluded and name in charts_by_name + and set(parents_of[cid]) & set(root)) + chart["cross_filter_scope"] = in_scope or "none" + + +def _tab_links_to_spec(layout: dict, position: dict, slug: str) -> None: + """Links to this dashboard's tabs (/superset/dashboard//#, as compile + writes them) back to [words](tab:Title) / (tab:Parent/Child), when the decompiled + layout holds that tab and the target names it alone (Layout.tabs_titled: a top tab + titled like a sub-tab's path names both) in a form a link carries; any other link + stays as written.""" + tabs = layout.get("tabs") or [] + named = [t["title"] for t in tabs] + [f"{t['title']}/{s['title']}" for t in tabs for s in t.get("tabs") or []] + targets = {tab_id: form for tab_id, form in _kept_tabs(layout, position).items() + if named.count(form) == 1 and TAB_LINK_RE.fullmatch(f"](tab:{form})")} + if not targets: + return + link = re.compile(rf"\]\(/superset/dashboard/{re.escape(slug)}/#(TAB-[A-Za-z0-9_-]+)\)") + + def unlink(block: dict) -> None: + block["markdown"] = link.sub( + lambda m: f"](tab:{targets[m.group(1)]})" if m.group(1) in targets else m.group(0), + block["markdown"]) + + holders = [layout, *_leaf_tabs(layout)] + rows = [r for h in holders for key in ("rows", "header", "footer") for r in h.get(key) or []] + for items in (row_items(r) for r in rows): + for item in items or []: + if isinstance(item, dict): + unlink(item) + for h in holders: + for entry in (h.get("legend") or {}).values(): + if isinstance(entry, dict) and "markdown" in entry: + unlink(entry) + + _SKETCH_SYMBOLS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" @@ -1782,6 +1960,8 @@ def entry(node: dict): if not (meta.get("code") or "").strip(): return None block: dict = {"markdown": meta["code"]} + if mid := _markdown_id(node.get("id")): + block["id"] = mid if _markdown_height(meta) is not None: block["height"] = _markdown_height(meta) return ("markdown", block) @@ -2172,6 +2352,9 @@ def width_of(item) -> int: ordered_names.append(x) ordered = [charts_by_name[n] for n in ordered_names] + _tab_links_to_spec(layout, position, slug) + _cross_filter_scopes_to_spec(dash.get("metadata") or {}, position, layout, charts_by_name, + chart_uuids, losses) label_colors = (dash.get("metadata") or {}).get("label_colors") or {} css = dash.get("css") if isinstance(dash.get("css"), str) else "" spec = { diff --git a/chartwright/design/content.py b/chartwright/design/content.py index f9b8f9b..41d3e68 100644 --- a/chartwright/design/content.py +++ b/chartwright/design/content.py @@ -124,8 +124,10 @@ def normal_row(row) -> Any: width = i.width if width is None: # largest remainder, as compile resolves it width, k = max(1, base + (1 if k < rem else 0)), k + 1 - out.append({"markdown": i.markdown, "width": width, - "height": _num(i.height if i.height is not None else 4)}) + block = {"markdown": i.markdown, "width": width, + "height": _num(i.height if i.height is not None else 4)} + # A named block's id is its CSS target (MARKDOWN-), so it is part of the row. + out.append({**block, "id": i.id} if i.id else block) return {"row": out, "background": getattr(model, "background", "transparent")} diff --git a/chartwright/design/readability.py b/chartwright/design/readability.py index 4b34ae5..0dab3d4 100644 --- a/chartwright/design/readability.py +++ b/chartwright/design/readability.py @@ -40,8 +40,8 @@ theme.fontSizeLG (:80 at 6.1.0); `.header-title` inherits it. - Big numbers: BigNumberViz sizes each line to a share of the card's height (PROPORTION and computeMaxFontSize, BigNumberViz.tsx:36-57 and :275-321 at 4.1.4), and - chartwright pins header_font_size 0.4 and subheader_font_size 0.15 - (compiler._pin_big_number_fonts). The size is an inline style, so only CSS marked + chartwright pins header_font_size 0.4 and subheader_font_size 0.15 unless the chart + sets its own (compiler._pin_big_number_fonts). The size is an inline style, so only CSS marked `!important` changes it. Theme tokens (6.0 and later): Superset lets antd derive every map token from the seed, @@ -921,6 +921,10 @@ def short(units: float) -> list[str]: """What the card's height draws below its floor; a size the CSS pins is the CSS finding's, whatever the height.""" value, label = kpi_px(c.type, units) + # ponytail: the tables were measured at 0.4 / 0.15; a chart's own share scales them + # linearly, as BigNumberViz sizes text by height x share. Measure per size if it misleads. + value *= (c.header_font_size or 0.4) / 0.4 + label *= (getattr(c, "subtitle_font_size", None) or 0.15) / 0.15 if cap is not None: label = min(label, cap) out = ([f"value at {value:.0f} px"] diff --git a/chartwright/spec.py b/chartwright/spec.py index 43eb07c..32a98cd 100644 --- a/chartwright/spec.py +++ b/chartwright/spec.py @@ -365,6 +365,13 @@ class _ChartBase(BaseModel): cache_timeout: int | None = Field( default=None, ge=1, description="Seconds Superset caches this chart's query results; omit for the default") tags: list[str] | None = Field(default=None, description=TAGS_DESCRIPTION) + cross_filter_scope: Literal["global", "tab", "none"] | list[str] | None = Field( + default=None, + description="Which charts a click on this chart cross-filters: global (every chart, " + "Superset's default), tab (the charts in its own tab or sub-tab), none (no " + "chart, e.g. a summary table), or a list of chart names. Needs " + "dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls " + "emit no cross-filters") @model_validator(mode="after") def _chart_metadata(self) -> "_ChartBase": @@ -559,11 +566,24 @@ def paint_hex(self) -> str: return FORMAT_TEXT_HEX.get(self.color, self.color) +# The big-number text sizes Superset's panel offers, as shares of the card's height +# (BigNumber/sharedControls.ts FONT_SIZE_OPTIONS_LARGE / _SMALL, the same at 4.1.4, +# 5.0.0 and 6.1.0). 0.4 and 0.15 are the panel's defaults (SUPERSET_DEFAULTS). +HeaderFontSize = Literal[0.2, 0.3, 0.4, 0.5, 0.6] +SubtitleFontSize = Literal[0.125, 0.15, 0.2, 0.3, 0.4] + + class BigNumberChart(_ChartBase): type: Literal["big_number_total"] metric: str subtitle: str | None = None number_format: str | None = Field(default=None, description="d3 format string, e.g. ',.0f'") + header_font_size: HeaderFontSize | None = Field( + default=None, description="The number's size, a share of the card's height: 0.2, 0.3, " + "0.4 (Superset's default), 0.5 or 0.6") + subtitle_font_size: SubtitleFontSize | None = Field( + default=None, description="The subtitle's size, a share of the card's height: 0.125, " + "0.15 (Superset's default), 0.2, 0.3 or 0.4") date_format: str | None = Field( default=None, min_length=1, description="Show the number as a date, in a d3 time format, e.g. \"%a %-d %b %Y\" " @@ -608,6 +628,13 @@ class BigNumberTrendChart(_ChartBase): time_column: str time_grain: str | None = None number_format: str | None = None + header_font_size: HeaderFontSize | None = Field( + default=None, description="The number's size, a share of the card's height: 0.2, 0.3, " + "0.4 (Superset's default), 0.5 or 0.6") + trend_date_format: str | None = Field( + default=None, min_length=1, + description="d3 time format for the trendline's dates, in its tooltip (and its x axis " + "on 6.1.0), e.g. \"%b %Y\"; the number itself stays a number") compare_lag: int | None = Field( default=None, ge=1, description="Compare the latest value with the one this many time-grain steps " @@ -724,6 +751,18 @@ class _AxisChart(_ChartBase): x_axis_title: str | None = Field(default=None, description="Title under the x axis") y_axis_title: str | None = Field( default=None, description="Title above the value axis, e.g. its unit") + x_axis_title_margin: int | None = Field( + default=None, ge=0, + description="Pixels between the x axis and its title; omit for a gap that clears the " + "labels (30, 50 under rotated labels, 64 on a horizontal bar)") + y_axis_title_margin: int | None = Field( + default=None, ge=0, + description="Pixels between the value axis and its title; omit for 15 (30 on a " + "horizontal bar), e.g. 45 for a title along the axis that clears its labels") + y_axis_title_position: Literal["Left", "Top"] | None = Field( + default=None, + description="Top (above the axis, the default here) or Left (along it, turned on its side; " + "the default on a horizontal bar)") y_axis_min: float | None = Field( default=None, description="Bottom of the value axis. Superset hands the bound to the chart as the " @@ -773,6 +812,19 @@ def _annotation_names(self) -> "_AxisChart": raise ValueError(f"chart {self.name!r}: duplicate annotation names {dupes} (each is a series id)") return self + @model_validator(mode="after") + def _title_spacing(self) -> "_AxisChart": + # Superset reads a title's margin and position only beside the title + # (Timeseries/transformProps.ts:737-740 at 6.1.0); a mixed chart's secondary + # title takes the same two. + if self.x_axis_title_margin is not None and not self.x_axis_title: + raise ValueError(f"chart {self.name!r}: x_axis_title_margin needs x_axis_title") + y_titled = self.y_axis_title or getattr(self, "y_axis_title_secondary", None) + for key in ("y_axis_title_margin", "y_axis_title_position"): + if getattr(self, key) is not None and not y_titled: + raise ValueError(f"chart {self.name!r}: {key} needs y_axis_title") + return self + def _check_y_axis(self) -> None: _check_bounds(self.name, "", self.y_axis_min, self.y_axis_max, self.y_axis_log) @@ -1401,8 +1453,8 @@ class TreemapChart(_ChartBase, _ColorSchemeMixin): class MixedSeries(BaseModel): - """One of a mixed chart's two queries: its metrics, drawn as bars, a line or a - filled area, on the primary (left) or secondary (right) value axis. No release gives + """One of a mixed chart's two queries: its metrics, drawn as bars, a line, a + filled area or points, on the primary (left) or secondary (right) value axis. No release gives a query's line a width or dash of its own (docs/CONTRACTS.md). To draw a reference series lighter, such as last year under this year, put it in its own query and give it a pale colour with the dashboard's label_colors, or make it an area with a low @@ -1411,11 +1463,11 @@ class MixedSeries(BaseModel): model_config = ConfigDict(extra="forbid") metrics: list[str] = Field(min_length=1) - kind: Literal["bar", "line", "area"] = Field( + kind: Literal["bar", "line", "area", "scatter"] = Field( default="bar", - description="bar, line, or area: a line with the space under it filled. A line " - "draws over an area whichever query holds it, so an area under a line " - "takes either order", + description="bar, line, area (a line with the space under it filled), or scatter " + "(a point per value, no line). A line draws over an area whichever " + "query holds it, so an area under a line takes either order", ) opacity: float | None = Field( default=None, ge=0, le=1, @@ -1781,6 +1833,11 @@ def _distribution(self) -> "BoxPlotChart": "heatmap", "histogram", "funnel", "treemap", "mixed", "waterfall", "box_plot", ) +# Charts whose plugin declares no Behavior.InteractiveChart, so a click on them filters +# nothing: BigNumberTotal and BigNumberWithTrendline (DrillToDetail only), Heatmap, +# Histogram and Waterfall (no behaviors) index.ts at 4.1.4, 5.0.0 and 6.1.0. +NO_CROSS_FILTER_TYPES = ("big_number_total", "big_number_trend", "heatmap", "histogram", "waterfall") + class _FilterBase(BaseModel): model_config = ConfigDict(extra="forbid") @@ -1970,12 +2027,22 @@ def _scope(self) -> "TimeColumnFilter": DATASET_FILTER_TYPES = ("select", "range", "time_grain", "time_column") +# A markdown block's name: its component (and DOM) id is MARKDOWN-. +MARKDOWN_ID_PATTERN = r"^[a-z][a-z0-9-]{0,47}$" + + class MarkdownBlock(BaseModel): - """A text block in the layout (headers, notes).""" + """A text block in the layout (headers, notes). ``id`` names it: the block's + component (and DOM) id becomes ``MARKDOWN-``, so the dashboard's ``css`` can + style it as ``#MARKDOWN-``.""" model_config = ConfigDict(extra="forbid") markdown: str = Field(min_length=1) + id: str | None = Field( + default=None, pattern=MARKDOWN_ID_PATTERN, + description="A lowercase slug, unique in the layout, e.g. \"legend\": the block's id " + "is then MARKDOWN-legend, a target for the dashboard's css") width: int | None = Field(default=None, ge=1, le=GRID_WIDTH) height: int | float | None = Field( default=None, ge=0.2, le=100, @@ -1989,9 +2056,20 @@ def _whole_grid_rows(cls, h: int | float | None) -> int | float | None: raise ValueError(f"markdown height {h}: use fifths of a unit (0.2 = one 8 px grid row), e.g. 1.6") return h + @field_validator("id") + @classmethod + def _not_positional(cls, i: str | None) -> str | None: + if i is not None and i.startswith("sdc-"): + raise ValueError(f"markdown id {i!r}: the 'sdc-' prefix is reserved for unnamed blocks' ids") + return i + RowItem = Union[str, MarkdownBlock] +# A markdown link to a tab of the same dashboard: [words](tab:Title) or (tab:Parent/Child). +# A title may hold balanced parentheses, as a markdown link target may: (tab:Sales (EU)). +TAB_LINK_RE = re.compile(r"\]\(tab:((?:[^()]|\([^()]*\))+)\)") + class HeaderBlock(BaseModel): """A full-width section title between rows (Superset's Header component). @@ -2567,6 +2645,16 @@ def leaf_tabs(self) -> list[Tab]: """The tabs that hold content: each top tab, or its sub-tabs.""" return [leaf for tab in (self.tabs or []) for leaf in (tab.tabs or [tab])] + def tabs_titled(self, target: str) -> list[tuple[int, ...]]: + """Positions of the tabs a link's target names: a top tab by its title (top + titles are unique) and a sub-tab by its 'Parent/Child' path, both when a top tab + is titled like a path; else a sub-tab by its own title.""" + subs = [(k, j, f"{t.title}/{s.title}", s.title) + for k, t in enumerate(self.tabs or []) for j, s in enumerate(t.tabs or [])] + exact = ([(k,) for k, t in enumerate(self.tabs or []) if t.title == target] + + [(k, j) for k, j, path, _ in subs if path == target]) + return exact or [(k, j) for k, j, _, title in subs if title == target] + def all_rows(self) -> list[list[RowItem]]: """Every row of charts and markdown (headers and dividers skipped).""" body = self.rows or [row for tab in self.leaf_tabs() for row in (tab.rows or [])] @@ -2577,6 +2665,12 @@ def sketch_holders(self) -> list["_SketchHolder"]: return [self] return [t for t in self.leaf_tabs() if t.sketch] + def markdown_blocks(self) -> list[MarkdownBlock]: + """Every markdown block: in rows (header and footer included) and in sketch legends.""" + in_rows = [item for row in self.all_rows() for item in row if isinstance(item, MarkdownBlock)] + return in_rows + [entry for holder in self.sketch_holders() + for entry in (holder.legend or {}).values() if isinstance(entry, MarkdownBlock)] + class DashboardSpec(BaseModel): model_config = ConfigDict(extra="forbid") @@ -2813,6 +2907,57 @@ def _layout_consistent(self) -> "DashboardSpec": raise ValueError(f"charts not placed in layout: {sorted(missing)}") return self + # The checks below assume a consistent layout, so they run after it (a chart missing + # from the layout is named as such, not as a scope or link error). + @model_validator(mode="after") + def _cross_filter_scopes(self) -> "DashboardSpec": + names = {c.name for c in self.charts} + in_tabs = {x for leaf in self.layout.leaf_tabs() + for x in [*(i for row in item_rows(leaf.rows) for i in row), + *(leaf.legend or {}).values()] + if isinstance(x, str)} + for c in self.charts: + scope = c.cross_filter_scope + if scope in (None, "global"): + continue + where = f"chart {c.name!r}: cross_filter_scope" + if c.type in NO_CROSS_FILTER_TYPES: + raise ValueError(f"{where}: a {c.type} chart emits no cross-filters") + if not self.dashboard.cross_filters: + raise ValueError(f"{where} needs dashboard.cross_filters: true") + if scope == "tab" and c.name not in in_tabs: + raise ValueError(f"{where} 'tab' needs the chart placed in a tab") + if isinstance(scope, list): + bad = [n for n in scope if n not in names or n == c.name] + if not scope or bad or len(set(scope)) != len(scope): + raise ValueError(f"{where} must list other spec charts, once each: {scope}") + return self + + @model_validator(mode="after") + def _tab_links_resolve(self) -> "DashboardSpec": + valid = [t.title if s is None else f"{t.title}/{s.title}" + for t in self.layout.tabs or [] for s in [None, *(t.tabs or [])]] + for block in self.layout.markdown_blocks(): + for target in TAB_LINK_RE.findall(block.markdown): + found = self.layout.tabs_titled(target) + if not found: + raise ValueError(f"markdown link 'tab:{target}': no such tab; link one of {valid}") + if len(found) > 1 and any(len(p) == 1 for p in found): + raise ValueError(f"markdown link 'tab:{target}' is ambiguous: a top tab and a " + f"sub-tab both answer to it; rename one of them") + if len(found) > 1: + raise ValueError(f"markdown link 'tab:{target}' is ambiguous: {len(found)} sub-tabs " + f"have that title; write tab:Parent/Child, one of {valid}") + return self + + @model_validator(mode="after") + def _markdown_ids_unique(self) -> "DashboardSpec": + ids = [b.id for b in self.layout.markdown_blocks() if b.id is not None] + dupes = sorted({i for i in ids if ids.count(i) > 1}) + if dupes: + raise ValueError(f"duplicate markdown ids {dupes}: an id names one block in the layout") + return self + # -- geometry ------------------------------------------------------------- def _row_of(self, want) -> list[RowItem]: @@ -2932,15 +3077,18 @@ def load_spec(data: dict) -> DashboardSpec: # Treemap/types.ts:64 (:63 at 6.1.0)). x_label_format: x_axis_time_format's # DEFAULT_TIME_FORMAT, smart_date (sharedControls.tsx:320, :341 at 6.1.0; # D3Formatting.ts:67, :70 at 5.0.0, :78 at 6.1.0). x_label_rotation: xAxisLabelRotation - # 0 (plugin-chart-echarts src/defaults.ts:31). + # 0 (plugin-chart-echarts src/defaults.ts:31). cross_filter_scope: a chart with no + # chart_configuration entry follows the dashboard's scope (dashboard/types.ts:87-106). "chart": {"time_range": "No filter", "number_format": "SMART_NUMBER", "number_format_secondary": "SMART_NUMBER", "x_label_format": "smart_date", - "x_label_rotation": 0}, + "x_label_rotation": 0, "cross_filter_scope": "global"}, # table_timestamp_format: SMART_DATE_ID (plugin-chart-table controlPanel.tsx:394, :413 # at 5.0.0, :488 at 6.1.0). color_pn defaults true, align_pn false (4.1.4 :523, :509; # 5.0.0 :572, :558; 6.1.0 :692, :678), filled in on a dashboard by applyDefaultFormData. "table": {"date_format": "smart_date", "color_by_sign": True, "absolute_bars": False}, - "big_number_trend": {"trend_color": TREND_DEFAULT_HEX}, + # header_font_size / subheader_font_size: BigNumber/sharedControls.ts, 4.1.4 to 6.1.0. + "big_number_total": {"header_font_size": 0.4, "subtitle_font_size": 0.15}, + "big_number_trend": {"trend_color": TREND_DEFAULT_HEX, "header_font_size": 0.4}, "timeseries_line": {"marker_size": 6, "opacity": 0.2}, "timeseries_area": {"marker_size": 6, "opacity": 0.2}, "timeseries_scatter": {"marker_size": 6}, diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index c6bd0a3..6b96e42 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -62,6 +62,62 @@ running the tool against real instances of all three releases. 4.1.4/5.0.0, `:187` at 6.1.0). An apply therefore replaces CSS edited in the UI with the spec's `css`, or clears it when the spec has none; `plan` reports the difference first. +- **A named markdown block is a CSS target.** Superset renders a layout + component's id as its DOM id (`gridComponents/Markdown/Markdown.tsx:418` + at 6.1.0, `gridComponents/Markdown.jsx:363` at 4.1.4 and 5.0.0), so a + block's `id` compiles to the component id `MARKDOWN-` and the + dashboard's `css` can style it as `#MARKDOWN-`. An unnamed block keeps + its positional `MARKDOWN-sdc-...` id, whose `sdc-` prefix a name can't + take. The UI mints `MARKDOWN-`, mixed case and `_` + (`dashboard/util/newComponentFactory.ts:74` at 6.1.0), which decompile + never reads as a name; it reads `MARKDOWN-` back as the block's `id`, + so `plan` reports a renamed block as a layout change. +- **A link to a tab is the dashboard's URL with the tab's id in the hash.** + In markdown, `[words](tab:Title)` or `(tab:Parent/Child)` compiles to + `/superset/dashboard//#`. Superset opens the tab the hash + names when the dashboard loads: `getLocationHash` + (`dashboard/util/getLocationHash.ts:20`) feeds `directPathToChild`, the + component's parents plus itself (`dashboard/actions/hydrate.ts:287-294` at + 6.1.0, `hydrate.js:222-226` at 4.1.4 and 5.0.0), and each tab set starts on + the child on that path (`gridComponents/Tabs/Tabs.tsx:135-140` at 6.1.0, + `Tabs.jsx:190-193` at 4.1.4 and `:139-142` at 5.0.0). A hash change on the + open page is not read, so the link is the full URL. A signed-in viewer's URL + carries a `native_filters_key` query, which Superset adds on every load, so a + click loads the dashboard again and filter selections not saved in the URL + reset (seen live on 6.1.0, 2026-10-06). Where Superset can't save the + viewer's filter state, the URL has no query, a click only changes the hash, + and the tab stays. A bare title names a top tab, or else a sub-tab; + validation lists the targets for an unknown title and asks for + `Parent/Child` when two sub-tabs share one. A top tab titled like a + sub-tab's `Parent/Child` path is refused as ambiguous, and decompile leaves + a link to either as its URL. + Decompile turns links to this dashboard's tabs back into `tab:` form, and + `plan` compares links by the tab they open, so `tab:Trend` and + `tab:Sales/Trend` are the same link. +- **A chart's cross-filter scope lives in the dashboard's + `chart_configuration`, keyed by chart id.** 6.1.0 stores `{id, crossFilters: + {scope: {rootPath, excluded}, chartsInScope}}` per chart + (`superset-frontend/src/dashboard/types.ts:87-106`). `"tab"` roots the scope + at the chart's innermost tab; a list roots it at `ROOT_ID` and excludes every + other chart; the chart's own id is excluded, as the scoping modal saves it + (`nativeFilters/FilterBar/CrossFilters/ScopingModal/ScopingModal.tsx:240-246`). + `"none"` is what the scoping tree saves with nothing ticked, an empty root + (`FiltersConfigForm/FilterScope/utils.ts:261-270`), which reaches no chart, + one added later included (`util/getChartIdsInFilterScope.ts:75-85`). Chart + ids don't exist at compile time: the bundle carries the layout's + placeholders, which the 6.1.0 importer remaps + (`superset/commands/dashboard/importers/v1/utils.py:147-190`) and 4.1.4 and + 5.0.0 leave alone, so `apply`'s scope stage rewrites `chart_configuration` + from the live layout's ids on every release. `restore` maps the backup's own + entries to those ids by chart uuid, as the 6.1.0 importer does + (`importers/v1/utils.py:48-71`), `chartsInScope` included, which that + importer leaves: a chart decompile can't read keeps its scope. Big + numbers, heatmaps, histograms and waterfalls declare no + `Behavior.InteractiveChart` (their `index.ts` at 4.1.4, 5.0.0 and 6.1.0), so + a click on them filters nothing and validation refuses a scope there. + Decompile maps ids back to names through the layout, so `plan` reports a + scope changed in the UI as a changed chart, and an adopted dashboard keeps + the scopes it was built with. - **Tags import on 6.0.0 or later, and only with tagging turned on.** The dashboard and chart import schemas gain `tags` at 6.0.0 (`superset/dashboards/schemas.py:502`, `superset/charts/schemas.py:1589`; @@ -461,7 +517,15 @@ running the tool against real instances of all three releases. spec's defaults match it: `only_total` and a heatmap's `show_percentage` default to true, and the tool writes them only as false. Axis titles get an explicit margin, because 6.1.0's default x-title margin is 0, which draws - the title over the tick labels. + the title over the tick labels. An axis chart can set its own + `x_axis_title_margin`, `y_axis_title_margin` and `y_axis_title_position` + (`Left` or `Top`), the titleControls keys (`sections/chartTitle.tsx:41-101` + at 6.1.0, the same names at 4.1.4 and 5.0.0); each needs its title, which + is the only place Superset reads it (`Timeseries/transformProps.ts:737-740`), + and a mixed chart's secondary title shares the y pair. Unset, the tool's + spacing applies, and written, that same spacing compares equal in `plan`. + Decompile reads a stored value that differs from the tool's spacing back as + the chart's own, Superset's `Left` included. - **A written Superset default draws the same chart as an omitted field.** Writing Superset's own value (`legend_position: "top"`, `legend_type: "scroll"`, a pie's `label_type: "key_percent"`, a funnel's `"key"`, a @@ -663,7 +727,19 @@ running the tool against real instances of all three releases. same pair (`BigNumberWithTrendline/controlPanel.tsx:139` and `:153` at 4.1.4 and 5.0.0, `:212` and `:226` at 6.1.0), but there `time_format` also formats the trendline's tooltip dates (and, at 6.1.0, its optional x axis), - so the spec offers `date_format` on the plain big number only. + so the spec offers `date_format` on the plain big number only. A trendline's + `trend_date_format` writes `time_format` unforced, for those dates alone + (`transformProps.ts:245-249` and `:275` at 6.1.0): forced, the number itself + would print as a date. +- **A big number's text sizes are shares of the card's height.** + `header_font_size` (0.2-0.6) and a total's `subtitle_font_size` + (0.125-0.4) take the options `BigNumber/sharedControls.ts` offers, the same + at 4.1.4, 5.0.0 and 6.1.0. Unset, the compiler still writes 0.4 and 0.15, the + panel's defaults: 6.1.0 reads a subtitle sent as the legacy `subheader` at + `subheaderFontSize ?? 1` (`BigNumberTotal/transformProps.ts:77-80`). The + subtitle size rides `subheader_font_size`, which every release reads. A + trendline's comparison line keeps 0.15. Decompile reads a size Superset + offers back and names any other. - **A mixed chart's area is a line with "Area chart" ticked.** `kind: "area"` writes `seriesType` `line` and `area` (query B: `areaB`), and its `opacity` (`opacityB`) scales the fill only; the edge line stays at full @@ -674,6 +750,10 @@ running the tool against real instances of all three releases. including the older `echarts_timeseries_line` and `echarts_timeseries_bar` names, draws a straight line (`Timeseries/transformers.ts:237-243` at 4.1.4, `:306-312` at 6.1.0), so decompile reads it as `kind: "line"`. + `kind: "scatter"` writes `seriesType` `scatter`, a point per value and no + line (`EchartsTimeseriesSeriesType.Scatter`, `Timeseries/types.ts:44-52` at + 6.1.0, the same enum at 4.1.4 and 5.0.0); smooth and the steps stay outside + the spec and decompile names them. - **A mixed chart draws every line solid, at one width.** `transformSeries` takes a series' line style only from its caller (`Timeseries/transformers.ts:273-276` at 4.1.4, `:287-290` at 5.0.0, diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 629f91f..d7832bd 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -34,7 +34,7 @@ Step-by-step guides: [move between instances](MOVE-BETWEEN-INSTANCES.md) · - On Superset 6.1+, each band is one solid colour, a rule can colour the text instead of the cell, and a table rule can read one column and paint another, or the whole row: a number coloured by the status beside it. Older releases fade each band toward its threshold and fill only the cell that was tested; on those, `check`, `apply` and `plan` warn for each such rule, naming what that release draws instead. - A fixed ascending table sort, d3 number and date formats, and on Superset 6.0+ hidden table columns. - **Chart Options**: Set the common options of Superset's chart panels in the spec; an option changed in the UI shows up in `plan`. - - Axes: titles, a fixed floor or ceiling, a log scale; a mixed chart's second axis takes its own. + - Axes: titles, a fixed floor or ceiling, a log scale; a mixed chart's second axis takes its own. A title can sit along its axis (`y_axis_title_position: "Left"`) at the gap you set (`y_axis_title_margin`). - Values written on bars and points, stacked series, 100% stacks, and the top N series of a breakdown. - Legends hidden, or placed at the bottom, left or right. - Bars in category order (hours, ranks, `1-Mon` weekdays) instead of by value; stacked or grouped bars ranked by their total; or bars ranked by another metric, drawn or not, so two charts share one order. @@ -42,8 +42,9 @@ Step-by-step guides: [move between instances](MOVE-BETWEEN-INSTANCES.md) · - A trendline KPI's change against an earlier period ("+4% vs last month"), its line colour, and on Superset 6.0+ a subtitle. - A trendline KPI over a rolling window: a trailing-12-month total compared with the 12 months before ("+30% vs prior 12 months"), a 3-month average, or a running total. - A trendline fitted to its values, so a trailing total that moves a few percent shows the movement. - - Filled areas on a mixed chart, such as solar output under a net-load line. + - Filled areas on a mixed chart, such as solar output under a net-load line, and points (`kind: "scatter"`) for a query read as single values, such as one-off events over a trend. - A big number shown as a date, such as the latest order date as "Sat 3 Oct 2026". + - A big number's text size: the number at 0.2-0.6 of the card's height, and a subtitle at 0.125-0.4, from Superset's own options. A trendline's dates in their own format, such as "Oct 2026". - Tables: page size, a totals row, a search box, column alignment and widths, and on Superset 6.0+ header names. - Table cell bars on the columns you name (`"cell_bars": ["Revenue", "Change"]`), coloured by sign on just the change (`"color_by_sign": ["Change"]`) so revenue bars stay neutral, and sized by absolute value where you list them (`"absolute_bars"`), so a rise and a fall of the same size draw the same bar. - Pivots: averages and other aggregations, rows sorted by value, row subtotals, rows and columns swapped. @@ -63,8 +64,8 @@ Step-by-step guides: [move between instances](MOVE-BETWEEN-INSTANCES.md) · - Name owners by email on 4.1.4 and 5.0.0, whose API returns usernames only with `FAB_ADD_SECURITY_API` on; usernames work on 6.1.0. - The account that applies stays an owner alongside them: Superset adds it on every import, and a non-admin account needs it to import the next version. - `plan` reports owners changed in the UI, and `decompile` reads them back. -- **Dashboard CSS**: `"css"` on the dashboard block holds what you would type into Superset's Edit CSS, so the styling is reviewed and versioned with the rest of the dashboard. CSS changed in the UI is drift that `plan` reports and `apply` replaces. -- **Cross-Filtering, Spec-Owned**: `"cross_filters": true` on the dashboard block turns on Superset's click-to-filter (a value clicked in one chart filters every chart whose dataset has that column, across tabs). Off by default; a toggle made in the UI is drift that `plan` reports and `apply` repairs. +- **Dashboard CSS**: `"css"` on the dashboard block holds what you would type into Superset's Edit CSS, so the styling is reviewed and versioned with the rest of the dashboard. CSS changed in the UI is drift that `plan` reports and `apply` replaces. Name a markdown block (`{"markdown": "...", "id": "legend"}`) and the CSS can style that block alone, as `#MARKDOWN-legend`. +- **Cross-Filtering, Spec-Owned**: `"cross_filters": true` on the dashboard block turns on Superset's click-to-filter (a value clicked in one chart filters every chart whose dataset has that column, across tabs). Off by default; a toggle made in the UI is drift that `plan` reports and `apply` repairs. A chart can narrow what its clicks reach with `cross_filter_scope`: `"tab"` (the charts in its own tab), `"none"` (no chart, such as a summary table), or a list of chart names. - **No Empty First Load**: New charts open on your full data range, so a narrow default time window never hides everything on the first paint. On a large dataset that full range is a lot to draw, so give the filter bar a time range with a default; `chartwright advise` tells you when a dashboard has nothing bounding its dates. ## Layout Design @@ -75,7 +76,7 @@ Step-by-step guides: [move between instances](MOVE-BETWEEN-INSTANCES.md) · - An unclear sketch gets a message saying exactly what to fix, never a guess. - Every rule drawn and compiled: [the layout guide](LAYOUT-GUIDE.md). - **Precise Sizing**: Set exact widths and heights per chart (markdown blocks in 8 px steps from 40 px: `"height": 1.6` is 64 px), or drag a chart taller in the UI and `chartwright absorb` writes the new height back into the spec; widths are a one-line edit in the layout. -- **Rows, Tabs, and Notes**: Even or custom row splits, titled tabs (with one level of sub-tabs, e.g. a sub-tab per row of a scorecard), section headers and dividers between rows (`{"header": "Revenue", "size": "large"}`, `{"divider": true}`), a white card behind a row, and markdown blocks for notes. +- **Rows, Tabs, and Notes**: Even or custom row splits, titled tabs (with one level of sub-tabs, e.g. a sub-tab per row of a scorecard), section headers and dividers between rows (`{"header": "Revenue", "size": "large"}`, `{"divider": true}`), a white card behind a row, and markdown blocks for notes. A note can link to a tab: `[definitions](tab:Definitions)` opens that tab for a viewer signed in to Superset (`tab:Sales/Trend` for a sub-tab). - **Header and Footer**: `layout.header` rows sit above everything and `layout.footer` rows below it, outside any tab, so a tabbed dashboard shows them above and under every tab: a banner, a data note, a branding strip, a contact line. - Adding a header to a dashboard already in use moves nothing else in it. @@ -227,4 +228,3 @@ Step-by-step guides: [move between instances](MOVE-BETWEEN-INSTANCES.md) · ## Deliberately Not Included - Exotic chart types (maps, sankey, gauge): added when a real dashboard needs one (ask); until then those dashboards live on in the UI, untouched. -- Per-chart cross-filter scoping (which charts emit or receive): the on/off switch is in the spec; finer scoping waits for real demand. diff --git a/docs/LIMITS.md b/docs/LIMITS.md index ba3a8d1..f4b6952 100644 --- a/docs/LIMITS.md +++ b/docs/LIMITS.md @@ -21,11 +21,12 @@ Each limit below comes with what to do instead, so you can tell whether Chartwri | **Charts added in the UI:** the next `apply` takes a chart added in the UI off the dashboard, names it in a warning, and leaves the chart itself in Superset's Charts list | Add new charts to the spec. To keep one that was added in the UI, describe it in the spec: the next apply builds Chartwright's own chart from it, and you can delete the UI-made one | | **Chart names:** a chart's name is its identity. Renaming a chart in the spec deletes the old chart and creates a new one with a new id (in an adopted spec, rename its key in `dashboard.adopted.charts` too, and the chart keeps its id) | Settle chart names before reports or links point at a chart. `plan` shows a rename as one chart removed and one added (in an adopted spec, as one chart changed) | | **Decompile:** `decompile` lists what it can't carry over in its `losses`, including chart settings changed from Superset's defaults, but drops a few dashboard settings without a note ([listed below](#settings-decompile-drops-without-a-note)) | Before you retire the original, compare it with the copy for those settings | -| **Taking over a dashboard made in the UI:** the first apply of an adopted spec resets what a spec can't hold, such as a forecast on a chart or per-chart cross-filter scopes | `chartwright adopt` lists every such reset and refuses until you pass `--accept-reset`. For a setting you need, rework the chart with fields the spec has (`chartwright schema` lists them), or leave that dashboard unadopted; run `plan` before the first apply | +| **Taking over a dashboard made in the UI:** the first apply of an adopted spec resets what a spec can't hold, such as a forecast on a chart or the dashboard-wide cross-filter scope | `chartwright adopt` lists every such reset and refuses until you pass `--accept-reset`. For a setting you need, rework the chart with fields the spec has (`chartwright schema` lists them), or leave that dashboard unadopted; run `plan` before the first apply | | **Decompile input:** `decompile` reads a live dashboard, not a ZIP export | Import the ZIP into a test instance and decompile it there | | **Chart types and queries:** 17 chart types. Metrics are a saved metric, SUM, AVG, COUNT, COUNT_DISTINCT, MIN or MAX of a column, or a custom SQL expression (`SQL(...) AS Label`); a chart filter is a WHERE condition or custom SQL. The filter bar has five filter types: value picker, time range, numeric range, time grain and time column. `check` can't look inside custom SQL, so the columns it names are listed under `unchecked_sql` | For anything else, save the SQL as a metric or a virtual dataset in Superset, and name it in the spec | | **`plan` detail:** `plan` names each changed chart but not the setting that changed (on an adopted spec it lists the changed settings under `chart_option_changes`). It compares only settings a spec can express | To see which setting changed, run `chartwright decompile --profile -o live.json` and diff `live.json` against your spec. Keep styling in the spec (`css`, `label_colors`, colour rules, text blocks) | | **Heights from the UI:** `absorb` copies heights only | Set widths in the spec: a chart's `width`, or its run length in a `sketch` | +| **Links to a tab:** a `tab:` link opens its tab for a viewer signed in to Superset, by loading the dashboard again, so filter selections not saved in the URL reset. A viewer Superset can't save filter state for stays on the current tab when they click | Name the tab in the link's words, such as "see the Definitions tab", so every viewer can open it from the tab bar | | **Design fixes on a sketch:** `advise --fix` writes an explicit height that overrides the sketch, so the drawing no longer matches | To keep the drawing true, make a chart taller by repeating its line in the sketch | | **MCP server:** it has no `restore`, `absorb`, `calibrate` or `compile` tool. It does save a backup before every build of an existing dashboard | Run those from the command line, for example `chartwright restore --profile ` | | **One dashboard per spec:** each spec describes one dashboard, and each command takes one spec, apart from the `standards` commands, which take a folder | Loop over a folder of specs in a shell script or a CI job | @@ -41,7 +42,7 @@ Each limit below comes with what to do instead, so you can tell whether Chartwri ## Settings decompile drops without a note -- **Dashboard:** per-chart and dashboard-wide cross-filter scopes, charts exempt from auto-refresh, charts shown expanded, staggered refresh and the colour namespace. `chartwright adopt` lists each of these it finds as a reset. +- **Dashboard:** the dashboard-wide cross-filter scope, charts exempt from auto-refresh, charts shown expanded, staggered refresh and the colour namespace. `chartwright adopt` lists each of these it finds as a reset. ## Related diff --git a/docs/MOVE-BETWEEN-INSTANCES.md b/docs/MOVE-BETWEEN-INSTANCES.md index 2380707..e1a82c8 100644 --- a/docs/MOVE-BETWEEN-INSTANCES.md +++ b/docs/MOVE-BETWEEN-INSTANCES.md @@ -122,7 +122,7 @@ Ownership comes from the slug: the dashboard's uuid is derived from its slug, an Adopt it on the instance where it lives. The spec that `adopt` writes names the dashboard and its charts by their own ids, so applying it updates the same dashboard: its address, id and chart ids stay, and links and embeds keep pointing at it. 1. Run `chartwright adopt regional-sales --profile dev -o regional-sales.json` (the slug or numeric id). It changes nothing in Superset. - - If the first apply would reset settings a spec can't hold, such as a forecast on a chart or per-chart cross-filter scopes, it lists each under `resets` and refuses. For a setting you need, rework the chart with fields the spec has (`chartwright schema` lists them), or leave the dashboard unadopted; pass `--accept-reset` to accept the rest. + - If the first apply would reset settings a spec can't hold, such as a forecast on a chart or the dashboard-wide cross-filter scope, it lists each under `resets` and refuses. For a setting you need, rework the chart with fields the spec has (`chartwright schema` lists them), or leave the dashboard unadopted; pass `--accept-reset` to accept the rest. - If some charts also sit on other dashboards, it refuses, since applying changes them there too: give those dashboards their own copies in Superset, or pass `--allow-shared`. 2. Run `chartwright plan regional-sales.json --profile dev`. It names each chart whose stored settings the first apply rewrites, under `chart_option_changes`. 3. Run `chartwright apply regional-sales.json --profile dev`. It backs the dashboard up first, as always. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index dc122e5..be92916 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -35,7 +35,7 @@ injection. | Layer | Proves | Where it runs | |---|---|---| -| Offline suite (2430 tests, 79 modules) | Contract, determinism, round-trips, credentials | every PR and push to main, Linux + Windows, mcp 1 and 2 | +| Offline suite (2465 tests, 80 modules) | Contract, determinism, round-trips, credentials | every PR and push to main, Linux + Windows, mcp 1 and 2 | | Chart-option contract | Every emitted chart option is declared by each version's plugin source | every PR and push to main | | Live guarantee check | Three specs applied (every chart type, every display control, every dashboard and filter control), per-chart data check, ids stable across re-apply | every PR and push to main, all 3 versions | | Standards content round trip | `standards apply` writes an org footer, a team header and two CSS blocks into a spec; it applies, `plan` stays clean, decompile reads the CSS markers and every managed row back, `--claim` rebuilds the record, re-apply stays clean | every PR and push to main, all 3 versions | diff --git a/schema/dashboard_spec.schema.json b/schema/dashboard_spec.schema.json index 58c840c..f559714 100644 --- a/schema/dashboard_spec.schema.json +++ b/schema/dashboard_spec.schema.json @@ -288,6 +288,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "x_axis_title": { "anyOf": [ { @@ -314,6 +338,51 @@ "description": "Title above the value axis, e.g. its unit", "title": "Y Axis Title" }, + "x_axis_title_margin": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pixels between the x axis and its title; omit for a gap that clears the labels (30, 50 under rotated labels, 64 on a horizontal bar)", + "title": "X Axis Title Margin" + }, + "y_axis_title_margin": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pixels between the value axis and its title; omit for 15 (30 on a horizontal bar), e.g. 45 for a title along the axis that clears its labels", + "title": "Y Axis Title Margin" + }, + "y_axis_title_position": { + "anyOf": [ + { + "enum": [ + "Left", + "Top" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Top (above the axis, the default here) or Left (along it, turned on its side; the default on a horizontal bar)", + "title": "Y Axis Title Position" + }, "y_axis_min": { "anyOf": [ { @@ -769,6 +838,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "type": { "const": "big_number_total", "title": "Type", @@ -803,6 +896,46 @@ "description": "d3 format string, e.g. ',.0f'", "title": "Number Format" }, + "header_font_size": { + "anyOf": [ + { + "enum": [ + 0.2, + 0.3, + 0.4, + 0.5, + 0.6 + ], + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The number's size, a share of the card's height: 0.2, 0.3, 0.4 (Superset's default), 0.5 or 0.6", + "title": "Header Font Size" + }, + "subtitle_font_size": { + "anyOf": [ + { + "enum": [ + 0.125, + 0.15, + 0.2, + 0.3, + 0.4 + ], + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The subtitle's size, a share of the card's height: 0.125, 0.15 (Superset's default), 0.2, 0.3 or 0.4", + "title": "Subtitle Font Size" + }, "date_format": { "anyOf": [ { @@ -1080,6 +1213,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "type": { "const": "big_number_trend", "title": "Type", @@ -1117,6 +1274,40 @@ "default": null, "title": "Number Format" }, + "header_font_size": { + "anyOf": [ + { + "enum": [ + 0.2, + 0.3, + 0.4, + 0.5, + 0.6 + ], + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The number's size, a share of the card's height: 0.2, 0.3, 0.4 (Superset's default), 0.5 or 0.6", + "title": "Header Font Size" + }, + "trend_date_format": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "d3 time format for the trendline's dates, in its tooltip (and its x axis on 6.1.0), e.g. \"%b %Y\"; the number itself stays a number", + "title": "Trend Date Format" + }, "compare_lag": { "anyOf": [ { @@ -1406,6 +1597,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "type": { "const": "box_plot", "title": "Type", @@ -2288,6 +2503,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "show_legend": { "default": true, "description": "false hides the legend", @@ -2588,6 +2827,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "type": { "const": "heatmap", "title": "Type", @@ -2987,6 +3250,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "type": { "const": "histogram", "title": "Type", @@ -3304,13 +3591,27 @@ }, "MarkdownBlock": { "additionalProperties": false, - "description": "A text block in the layout (headers, notes).", + "description": "A text block in the layout (headers, notes). ``id`` names it: the block's\ncomponent (and DOM) id becomes ``MARKDOWN-``, so the dashboard's ``css`` can\nstyle it as ``#MARKDOWN-``.", "properties": { "markdown": { "minLength": 1, "title": "Markdown", "type": "string" }, + "id": { + "anyOf": [ + { + "pattern": "^[a-z][a-z0-9-]{0,47}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A lowercase slug, unique in the layout, e.g. \"legend\": the block's id is then MARKDOWN-legend, a target for the dashboard's css", + "title": "Id" + }, "width": { "anyOf": [ { @@ -3517,6 +3818,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "x_axis_title": { "anyOf": [ { @@ -3543,41 +3868,86 @@ "description": "Title above the value axis, e.g. its unit", "title": "Y Axis Title" }, - "y_axis_min": { + "x_axis_title_margin": { "anyOf": [ { - "type": "number" + "minimum": 0, + "type": "integer" }, { "type": "null" } ], "default": null, - "description": "Bottom of the value axis. Superset hands the bound to the chart as the axis minimum on every release, so it widens the axis or cuts values below it off at the edge; omitted, the axis fits the data", - "title": "Y Axis Min" + "description": "Pixels between the x axis and its title; omit for a gap that clears the labels (30, 50 under rotated labels, 64 on a horizontal bar)", + "title": "X Axis Title Margin" }, - "y_axis_max": { + "y_axis_title_margin": { "anyOf": [ { - "type": "number" + "minimum": 0, + "type": "integer" }, { "type": "null" } ], "default": null, - "description": "Top of the value axis, e.g. 1 for a share that can't pass 100 %. Like y_axis_min it is the axis maximum on every release: values above it are cut off at the edge", - "title": "Y Axis Max" - }, - "y_axis_truncate": { - "default": false, - "description": "Fit the value axis to the data instead of always including zero (Superset's Truncate Y Axis); a y_axis_min or y_axis_max wins on its side", - "title": "Y Axis Truncate", - "type": "boolean" + "description": "Pixels between the value axis and its title; omit for 15 (30 on a horizontal bar), e.g. 45 for a title along the axis that clears its labels", + "title": "Y Axis Title Margin" }, - "y_axis_log": { - "default": false, - "description": "A logarithmic value axis", + "y_axis_title_position": { + "anyOf": [ + { + "enum": [ + "Left", + "Top" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Top (above the axis, the default here) or Left (along it, turned on its side; the default on a horizontal bar)", + "title": "Y Axis Title Position" + }, + "y_axis_min": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Bottom of the value axis. Superset hands the bound to the chart as the axis minimum on every release, so it widens the axis or cuts values below it off at the edge; omitted, the axis fits the data", + "title": "Y Axis Min" + }, + "y_axis_max": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Top of the value axis, e.g. 1 for a share that can't pass 100 %. Like y_axis_min it is the axis maximum on every release: values above it are cut off at the edge", + "title": "Y Axis Max" + }, + "y_axis_truncate": { + "default": false, + "description": "Fit the value axis to the data instead of always including zero (Superset's Truncate Y Axis); a y_axis_min or y_axis_max wins on its side", + "title": "Y Axis Truncate", + "type": "boolean" + }, + "y_axis_log": { + "default": false, + "description": "A logarithmic value axis", "title": "Y Axis Log", "type": "boolean" }, @@ -3791,7 +4161,7 @@ }, "MixedSeries": { "additionalProperties": false, - "description": "One of a mixed chart's two queries: its metrics, drawn as bars, a line or a\nfilled area, on the primary (left) or secondary (right) value axis. No release gives\na query's line a width or dash of its own (docs/CONTRACTS.md). To draw a reference\nseries lighter, such as last year under this year, put it in its own query and give\nit a pale colour with the dashboard's label_colors, or make it an area with a low\nopacity.", + "description": "One of a mixed chart's two queries: its metrics, drawn as bars, a line, a\nfilled area or points, on the primary (left) or secondary (right) value axis. No release gives\na query's line a width or dash of its own (docs/CONTRACTS.md). To draw a reference\nseries lighter, such as last year under this year, put it in its own query and give\nit a pale colour with the dashboard's label_colors, or make it an area with a low\nopacity.", "properties": { "metrics": { "items": { @@ -3803,11 +4173,12 @@ }, "kind": { "default": "bar", - "description": "bar, line, or area: a line with the space under it filled. A line draws over an area whichever query holds it, so an area under a line takes either order", + "description": "bar, line, area (a line with the space under it filled), or scatter (a point per value, no line). A line draws over an area whichever query holds it, so an area under a line takes either order", "enum": [ "bar", "line", - "area" + "area", + "scatter" ], "title": "Kind", "type": "string" @@ -4075,6 +4446,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "show_legend": { "default": true, "description": "false hides the legend", @@ -4354,6 +4749,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "type": { "const": "pivot_table", "title": "Type", @@ -5163,6 +5582,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "type": { "const": "table", "title": "Type", @@ -5786,6 +6229,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "x_axis_title": { "anyOf": [ { @@ -5812,6 +6279,51 @@ "description": "Title above the value axis, e.g. its unit", "title": "Y Axis Title" }, + "x_axis_title_margin": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pixels between the x axis and its title; omit for a gap that clears the labels (30, 50 under rotated labels, 64 on a horizontal bar)", + "title": "X Axis Title Margin" + }, + "y_axis_title_margin": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pixels between the value axis and its title; omit for 15 (30 on a horizontal bar), e.g. 45 for a title along the axis that clears its labels", + "title": "Y Axis Title Margin" + }, + "y_axis_title_position": { + "anyOf": [ + { + "enum": [ + "Left", + "Top" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Top (above the axis, the default here) or Left (along it, turned on its side; the default on a horizontal bar)", + "title": "Y Axis Title Position" + }, "y_axis_min": { "anyOf": [ { @@ -6291,6 +6803,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "x_axis_title": { "anyOf": [ { @@ -6317,6 +6853,51 @@ "description": "Title above the value axis, e.g. its unit", "title": "Y Axis Title" }, + "x_axis_title_margin": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pixels between the x axis and its title; omit for a gap that clears the labels (30, 50 under rotated labels, 64 on a horizontal bar)", + "title": "X Axis Title Margin" + }, + "y_axis_title_margin": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pixels between the value axis and its title; omit for 15 (30 on a horizontal bar), e.g. 45 for a title along the axis that clears its labels", + "title": "Y Axis Title Margin" + }, + "y_axis_title_position": { + "anyOf": [ + { + "enum": [ + "Left", + "Top" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Top (above the axis, the default here) or Left (along it, turned on its side; the default on a horizontal bar)", + "title": "Y Axis Title Position" + }, "y_axis_min": { "anyOf": [ { @@ -6760,6 +7341,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "x_axis_title": { "anyOf": [ { @@ -6786,6 +7391,51 @@ "description": "Title above the value axis, e.g. its unit", "title": "Y Axis Title" }, + "x_axis_title_margin": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pixels between the x axis and its title; omit for a gap that clears the labels (30, 50 under rotated labels, 64 on a horizontal bar)", + "title": "X Axis Title Margin" + }, + "y_axis_title_margin": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pixels between the value axis and its title; omit for 15 (30 on a horizontal bar), e.g. 45 for a title along the axis that clears its labels", + "title": "Y Axis Title Margin" + }, + "y_axis_title_position": { + "anyOf": [ + { + "enum": [ + "Left", + "Top" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Top (above the axis, the default here) or Left (along it, turned on its side; the default on a horizontal bar)", + "title": "Y Axis Title Position" + }, "y_axis_min": { "anyOf": [ { @@ -7271,6 +7921,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "x_axis_title": { "anyOf": [ { @@ -7297,6 +7971,51 @@ "description": "Title above the value axis, e.g. its unit", "title": "Y Axis Title" }, + "x_axis_title_margin": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pixels between the x axis and its title; omit for a gap that clears the labels (30, 50 under rotated labels, 64 on a horizontal bar)", + "title": "X Axis Title Margin" + }, + "y_axis_title_margin": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Pixels between the value axis and its title; omit for 15 (30 on a horizontal bar), e.g. 45 for a title along the axis that clears its labels", + "title": "Y Axis Title Margin" + }, + "y_axis_title_position": { + "anyOf": [ + { + "enum": [ + "Left", + "Top" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Top (above the axis, the default here) or Left (along it, turned on its side; the default on a horizontal bar)", + "title": "Y Axis Title Position" + }, "y_axis_min": { "anyOf": [ { @@ -7755,6 +8474,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "type": { "const": "treemap", "title": "Type", @@ -7976,6 +8719,30 @@ "description": "Superset tags, e.g. [\"finance\", \"weekly\"]. Superset 6.0.0 or later, with the TAGGING_SYSTEM feature flag on. 4.1.4 and 5.0.0 can't import a bundle that carries tags, so check, apply and plan refuse the field there before anything is written; 6.0.0 or later without the flag ignores them, so plan keeps reporting them. An apply replaces the object's tags with this list, so [] removes them all, tags added in the UI included; omit the field to leave tags alone, in apply and in plan.", "title": "Tags" }, + "cross_filter_scope": { + "anyOf": [ + { + "enum": [ + "global", + "tab", + "none" + ], + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Which charts a click on this chart cross-filters: global (every chart, Superset's default), tab (the charts in its own tab or sub-tab), none (no chart, e.g. a summary table), or a list of chart names. Needs dashboard.cross_filters; big numbers, heatmaps, histograms and waterfalls emit no cross-filters", + "title": "Cross Filter Scope" + }, "type": { "const": "waterfall", "title": "Type", diff --git a/skill/SKILL.md b/skill/SKILL.md index f075a4a..9e11b77 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -37,8 +37,8 @@ approximate it with a different mechanism. 1. `CW schema`: read the contract. Surface: 17 chart types (big numbers, timeseries line/bar/area/scatter, categorical bar, pie/donut, table, - pivot_table, heatmap, histogram, funnel, treemap, mixed: bars, lines or - filled areas on two axes, waterfall: a bridge whose `opening`, `steps` and + pivot_table, heatmap, histogram, funnel, treemap, mixed: bars, lines, + filled areas or points on two axes, waterfall: a bridge whose `opening`, `steps` and `closing` keep its order on 6.1+, box_plot: a distribution per group), metrics as saved names, `AGG(col) [AS Label]` or `SQL(expression) AS Label`, per-chart `filters` (WHERE: column/op/value or `sql`), goal lines (`annotations`) on line/bar/area/scatter/mixed, diff --git a/tests/test_adopt.py b/tests/test_adopt.py index 7c1ac5b..4ecc01a 100644 --- a/tests/test_adopt.py +++ b/tests/test_adopt.py @@ -181,7 +181,9 @@ def test_resets_list_what_the_first_apply_resets_without_a_loss(): """Decompile reads past these without a loss, so adopt checks the export itself.""" def ui_state(path, doc): if "/dashboards/" in path: - doc["metadata"]["chart_configuration"] = {"1": {"crossFilters": {"scope": "global"}}} + # a per-chart scope on a dashboard with cross-filtering off: decompile names it + doc["metadata"]["chart_configuration"] = {"1": {"crossFilters": { + "scope": {"rootPath": ["ROOT_ID"], "excluded": [2]}}}} doc["metadata"]["timed_refresh_immune_slices"] = [1] doc["metadata"]["native_filter_configuration"] = [ {"id": "NATIVE_FILTER-ui", "name": "Region", "filterType": "filter_select", @@ -436,6 +438,21 @@ def test_plan_reads_a_renamed_adopted_chart_as_a_change(live): assert p.charts_changed == ["Orders (all time)"] and not p.charts_added and not p.charts_removed +def test_plan_reads_a_scope_naming_a_renamed_chart_as_unchanged(live): + """A cross-filter scope names its charts; after a rename in the adopted spec, the live + scope names the same chart under its old title, and the scope itself hasn't changed.""" + data = _fixture() + data["dashboard"]["cross_filters"] = True + data["charts"][3]["cross_filter_scope"] = ["Sales by Deal Size"] # Sales Over Time + fake, adopted = live(data=data) + # the scope under the live chart ids, as apply writes it (the fake imports ids as 4.1.4 does) + assert apply_mod._apply_cross_filter_scopes(load_spec(adopted), fake, _dash_id(fake)) == [] + assert dashdiff.plan(load_spec(adopted), fake).clean + renamed = json.loads(json.dumps(adopted).replace('"Sales by Deal Size"', '"Deal size mix"')) + p = dashdiff.plan(load_spec(renamed), fake) + assert p.charts_changed == ["Deal size mix"] and not p.charts_added and not p.charts_removed + + def test_plan_blocks_where_apply_would_refuse(live): fake, data = live() fake.charts[fake.linked(_dash_id(fake))["Total Sales"]]["dashboards"].clear() # taken off in the UI @@ -617,8 +634,9 @@ def ui_meta(path, doc): p = dashdiff.plan(load_spec(data), fake) assert not p.clean assert {"expanded_slices", "stagger_refresh", "global_chart_configuration", - "chart_configuration", "timed_refresh_immune_slices", - "color_namespace"} <= set(p.dashboard_settings_changed) + "timed_refresh_immune_slices", "color_namespace"} <= set(p.dashboard_settings_changed) + # per-chart scopes are each chart's cross_filter_scope now, and this one sets none + assert "chart_configuration" not in p.dashboard_settings_changed hand, _, spec = _hand_built(edit=ui_meta) whats = " ".join(r["what"] for r in first_apply_resets( decompile_bundle(hand, _stub_lookup_for(spec)), hand)) diff --git a/tests/test_decompile.py b/tests/test_decompile.py index 80436c3..b6bf2b6 100644 --- a/tests/test_decompile.py +++ b/tests/test_decompile.py @@ -248,9 +248,9 @@ def test_a_column_beside_markdown_reads_back_as_a_sketch_with_the_block(): def text_row(path, doc): if "/dashboards/" in path: pos = doc["position"] - pos["MARKDOWN-x"] = {"type": "MARKDOWN", "id": "MARKDOWN-x", "children": [], + pos["MARKDOWN-xQ7_b"] = {"type": "MARKDOWN", "id": "MARKDOWN-xQ7_b", "children": [], "meta": {"code": "Notes", "width": 12, "height": 8}} - pos["ROW-x"] = {"type": "ROW", "id": "ROW-x", "children": ["MARKDOWN-x"], + pos["ROW-x"] = {"type": "ROW", "id": "ROW-x", "children": ["MARKDOWN-xQ7_b"], "meta": {"background": "BACKGROUND_TRANSPARENT"}} pos["HEADER-x"] = {"type": "HEADER", "id": "HEADER-x", "children": [], "meta": {"text": "Detail", "headerSize": "MEDIUM_HEADER", diff --git a/tests/test_layout_footer.py b/tests/test_layout_footer.py index b54e306..8d2a436 100644 --- a/tests/test_layout_footer.py +++ b/tests/test_layout_footer.py @@ -31,6 +31,12 @@ def _round_trip(data: dict): return spec, decompile_bundle(compile_bundle(spec, stub_resolution(spec)), _stub_lookup_for(spec)) +# Component ids as Superset's UI mints them: `${type}-${nanoid()}`, mixed case and '_' +# (dashboard/util/newComponentFactory.ts:74 at 6.1.0), never a lowercase slug, which +# decompile would read as a named markdown block's id. +UI_ID = "uiB_7Qx-" + + def _renamed(bundle: bytes, old: str, new: str) -> bytes: """The same bundle with `old` replaced by `new` in the dashboard file: a dashboard whose footer rows were NOT built by chartwright.""" @@ -76,7 +82,7 @@ def test_any_rows_after_the_tabs_read_as_the_footer(): """A dashboard assembled in Superset's UI (no chartwright ids): rows dragged below the tabs are a footer too.""" spec = load_spec(_with_footer("kitchen_sink.json")) - bundle = _renamed(compile_bundle(spec, stub_resolution(spec)), "sdc-footer-", "ui-built-") + bundle = _renamed(compile_bundle(spec, stub_resolution(spec)), "sdc-footer-", UI_ID) result = decompile_bundle(bundle, _stub_lookup_for(spec)) assert result.spec["layout"]["footer"] == FOOTER @@ -85,7 +91,7 @@ def test_untabbed_rows_without_the_marker_stay_in_the_body(): """Without tabs there is no visual boundary: only chartwright's marked rows are a footer; anything else stays a body row (nothing is lost).""" spec = load_spec(_with_footer("sales_overview.json")) - bundle = _renamed(compile_bundle(spec, stub_resolution(spec)), "sdc-footer-", "ui-built-") + bundle = _renamed(compile_bundle(spec, stub_resolution(spec)), "sdc-footer-", UI_ID) layout = decompile_bundle(bundle, _stub_lookup_for(spec)).spec["layout"] assert "footer" not in layout assert layout["rows"][-1] == FOOTER[0] diff --git a/tests/test_layout_header.py b/tests/test_layout_header.py index 079f4f0..b469df4 100644 --- a/tests/test_layout_header.py +++ b/tests/test_layout_header.py @@ -23,7 +23,7 @@ from chartwright.testing import stub_resolution from test_decompile import _stub_lookup_for -from test_layout_footer import FOOTER, _renamed +from test_layout_footer import FOOTER, UI_ID, _renamed FIXTURES = Path(__file__).parent / "fixtures" HEADER = [[{"markdown": "**Finance view** · figures are provisional until the 5th", "width": 12, @@ -125,7 +125,7 @@ def test_any_rows_before_the_tabs_read_as_the_header(): """A dashboard assembled in Superset's UI (no chartwright ids): rows dragged above the tabs are a header, not a reason to flatten the tabs.""" spec = load_spec(_with_header("kitchen_sink.json")) - bundle = _renamed(compile_bundle(spec, stub_resolution(spec)), "sdc-header-", "ui-built-") + bundle = _renamed(compile_bundle(spec, stub_resolution(spec)), "sdc-header-", UI_ID) result = decompile_bundle(bundle, _stub_lookup_for(spec)) assert result.spec["layout"]["header"] == HEADER assert "tabs" in result.spec["layout"] @@ -134,7 +134,7 @@ def test_any_rows_before_the_tabs_read_as_the_header(): def test_untabbed_rows_without_the_marker_stay_in_the_body(): spec = load_spec(_with_header("sales_overview.json")) - bundle = _renamed(compile_bundle(spec, stub_resolution(spec)), "sdc-header-", "ui-built-") + bundle = _renamed(compile_bundle(spec, stub_resolution(spec)), "sdc-header-", UI_ID) layout = decompile_bundle(bundle, _stub_lookup_for(spec)).spec["layout"] assert "header" not in layout assert layout["rows"][0] == HEADER[0] diff --git a/tests/test_presentation_controls.py b/tests/test_presentation_controls.py new file mode 100644 index 0000000..94985cc --- /dev/null +++ b/tests/test_presentation_controls.py @@ -0,0 +1,687 @@ +"""Presentation controls: points on a mixed chart, big-number text sizes and a +trendline's date format, axis-title spacing, named markdown blocks, links to a tab, +and per-chart cross-filter scopes. Every key emitted is checked against the Superset +source (paths in the code beside each); each round-trips through decompile and shows +up in `plan` when the spec value changes.""" + +import io +import json +import sys +import zipfile +from pathlib import Path + +import pytest +import yaml +from pydantic import ValidationError + +from chartwright import dashdiff, ids +from chartwright.compiler import compile_bundle +from chartwright.dashdiff import _normalize +from chartwright.decompile import decompile_bundle +from chartwright.spec import load_spec +from chartwright.testing import edit_bundle, stub_resolution + +sys.path.insert(0, str(Path(__file__).parent.parent / "tools")) +from params_drift import CONTRACT, check # noqa: E402 + +DS = {"database": "examples", "table": "orders"} +LINE = {"name": "Revenue", "type": "timeseries_line", "dataset": DS, + "metrics": ["SUM(revenue)"], "time_column": "order_date", "time_grain": "P1M"} +KPI = {"name": "Orders", "type": "big_number_total", "dataset": DS, "metric": "COUNT(*)"} +TREND = {"name": "Orders trend", "type": "big_number_trend", "dataset": DS, "metric": "COUNT(*)", + "time_column": "order_date", "time_grain": "P1M"} +MIXED = {"name": "Volume and delays", "type": "mixed", "dataset": DS, "x_column": "order_date", + "time_grain": "P1M", "a": {"metrics": ["COUNT(*)"]}, + "b": {"metrics": ["MAX(delay)"], "kind": "line", "axis": "secondary"}} + + +def _spec(charts=None, dashboard=None, layout=None): + charts = charts or [KPI, LINE] + return load_spec({ + "spec_version": "1", + "dashboard": {"title": "Sales", "slug": "sales", **(dashboard or {})}, + "charts": charts, + "layout": layout or {"rows": [[c["name"]] for c in charts]}, + }) + + +def _compiled(spec) -> bytes: + return compile_bundle(spec, stub_resolution(spec)) + + +def _docs(bundle: bytes) -> dict[str, dict]: + zf = zipfile.ZipFile(io.BytesIO(bundle)) + return {n: yaml.safe_load(zf.read(n)) for n in zf.namelist() if n.endswith(".yaml")} + + +def _dashboard(spec) -> dict: + return next(d for n, d in _docs(_compiled(spec)).items() if "/dashboards/" in n) + + +def _params(spec) -> dict: + return {d["slice_name"]: d["params"] for n, d in _docs(_compiled(spec)).items() if "/charts/" in n} + + +def _lookup(spec): + by_uuid = {ds.uuid: {"database": ds.database_name, "schema": ds.schema, "table": ds.table} + for ds in stub_resolution(spec).datasets.values()} + return lambda u: by_uuid.get(u) + + +def _decompile(spec, edit=None): + """Decompile `spec`'s bundle, after `edit(path, doc)` when given: what a UI user changed.""" + bundle = _compiled(spec) + if edit is not None: + bundle = edit_bundle(bundle, edit) + return decompile_bundle(bundle, _lookup(spec)) + + +def _edit_chart(name, **params): + def edit(path, doc): + if "/charts/" in path and doc["slice_name"] == name: + doc["params"].update(params) + return edit + + +def _edit_metadata(**metadata): + def edit(path, doc): + if "/dashboards/" in path: + doc["metadata"].update(metadata) + return edit + + +def _round_trips(spec): + result = _decompile(spec) + assert result.losses == [], result.losses_json() + assert _normalize(load_spec(result.spec)) == _normalize(spec) + return result + + +def _contract_clean(emitted: dict[str, set[str]]) -> None: + """Every key emitted is one the viz type's control panel declares, on every release + (6.1.0-only keys are allowed, and ignored, before 6.1.0).""" + contract = json.loads(CONTRACT.read_text(encoding="utf-8")) + for version in contract: + assert check(version, contract, emitted) == [], version + + +def _plan(target, live, monkeypatch) -> dict: + """`plan` against a live dashboard that is `live` compiled and exported.""" + import chartwright.resolver as resolver + + monkeypatch.setattr(resolver, "resolve", lambda s, c, *_: stub_resolution(s)) + monkeypatch.setattr(dashdiff, "decompile_live", lambda slug, c: _decompile(live)) + + class Client: + def find_dashboard_by_slug(self, slug): + return {"id": 1, "uuid": str(ids.dashboard_uuid(slug))} + + return json.loads(dashdiff.plan(target, Client()).to_json()) + + +# -- a mixed chart's points ------------------------------------------------------------ + +def test_mixed_series_can_be_scatter(monkeypatch): + """kind scatter writes seriesType / seriesTypeB "scatter" (MixedTimeseries/ + controlPanel.tsx seriesType choices; EchartsTimeseriesSeriesType.Scatter).""" + points = {**MIXED, "b": {**MIXED["b"], "kind": "scatter"}} + p = _params(_spec([points]))[MIXED["name"]] + assert (p["seriesType"], p["seriesTypeB"]) == ("bar", "scatter") + assert "areaB" not in p + _round_trips(_spec([points])) + plan = _plan(_spec([points]), _spec([MIXED]), monkeypatch) + assert plan["charts_changed"] == [MIXED["name"]] + # a UI chart's points read back as points, not as a line with a loss + result = _decompile(_spec([MIXED]), _edit_chart(MIXED["name"], seriesType="scatter")) + assert result.losses == [], result.losses_json() + assert result.spec["charts"][0]["a"]["kind"] == "scatter" + + +# -- big-number text sizes and a trendline's dates --------------------------------------- + +def test_big_number_sizes_default_to_the_pinned_values(): + p = _params(_spec([KPI, TREND])) + assert (p["Orders"]["header_font_size"], p["Orders"]["subheader_font_size"]) == (0.4, 0.15) + assert (p["Orders trend"]["header_font_size"], p["Orders trend"]["subheader_font_size"]) == (0.4, 0.15) + assert not {"time_format", "force_timestamp_formatting"} & (set(p["Orders"]) | set(p["Orders trend"])) + + +def test_big_number_sizes_and_the_trendline_date_format(monkeypatch): + """BigNumber/sharedControls.ts (the size options, 4.1.4 to 6.1.0); a trendline's + time_format formats its dates (BigNumberWithTrendline/transformProps.ts:245-249, :275 + at 6.1.0) and is never forced, which would print the number itself as a date.""" + last = {**KPI, "name": "Last order", "metric": "MAX(order_date)", "date_format": "%b %d, %Y", + "header_font_size": 0.3, "subtitle_font_size": 0.2, "subtitle": "most recent"} + trend = {**TREND, "trend_date_format": "%b %Y", "header_font_size": 0.5} + spec = _spec([last, trend]) + p = _params(spec) + assert (p["Last order"]["header_font_size"], p["Last order"]["subheader_font_size"]) == (0.3, 0.2) + assert p["Orders trend"]["time_format"] == "%b %Y" + assert "force_timestamp_formatting" not in p["Orders trend"] + assert p["Orders trend"]["header_font_size"] == 0.5 + _round_trips(spec) + _contract_clean({"big_number_total": set(p["Last order"]), "big_number": set(p["Orders trend"])}) + plan = _plan(spec, _spec([{**last, "header_font_size": 0.4}, trend]), monkeypatch) + assert plan["charts_changed"] == ["Last order"] + plan = _plan(spec, _spec([last, {**trend, "trend_date_format": "%Y"}]), monkeypatch) + assert plan["charts_changed"] == ["Orders trend"] + + +def test_written_default_sizes_build_and_plan_as_omitted(): + written = _spec([{**KPI, "header_font_size": 0.4, "subtitle_font_size": 0.15}, + {**TREND, "header_font_size": 0.4}]) + omitted = _spec([KPI, TREND]) + assert _compiled(written) == _compiled(omitted) + assert _normalize(written) == _normalize(omitted) + + +def test_big_number_sizes_take_only_superset_options(): + with pytest.raises(ValidationError): + _spec([{**KPI, "header_font_size": 0.45}]) + with pytest.raises(ValidationError): + _spec([{**TREND, "subtitle_font_size": 0.2}]) # a trendline has no subtitle size + + +def test_big_number_decompile_reads_ui_sizes_and_names_the_rest(): + result = _decompile(_spec([KPI, TREND]), _edit_chart("Orders", header_font_size=0.6, + subheader_font_size=0.3)) + assert result.losses == [], result.losses_json() + back = result.spec["charts"][0] + assert (back["header_font_size"], back["subtitle_font_size"]) == (0.6, 0.3) + result = _decompile(_spec([KPI]), _edit_chart("Orders", header_font_size=0.45)) + assert [loss.what for loss in result.losses] == [ + "header_font_size 0.45 is not one of Superset's sizes; dropped"] + # a trendline's comparison size is no field: a changed one is a named setting + result = _decompile(_spec([TREND]), _edit_chart("Orders trend", subheader_font_size=0.3)) + assert any("subheader_font_size=0.3" in loss.what for loss in result.losses), result.losses_json() + + +# -- axis-title spacing ---------------------------------------------------------------- + +def test_unset_title_spacing_is_the_tools_own(): + titled = {**LINE, "x_axis_title": "Month", "y_axis_title": "USD"} + p = _params(_spec([titled]))["Revenue"] + assert (p["x_axis_title_margin"], p["y_axis_title_margin"], p["y_axis_title_position"]) == (30, 15, "Top") + + +def test_title_spacing_wins_and_round_trips(monkeypatch): + """titleControls (sections/chartTitle.tsx:41-101 at 6.1.0, the same keys at 4.1.4 and + 5.0.0): x_axis_title_margin, y_axis_title_margin and y_axis_title_position.""" + titled = {**LINE, "x_axis_title": "Month", "x_axis_title_margin": 15, "y_axis_title": "USD", + "y_axis_title_margin": 45, "y_axis_title_position": "Left"} + mixed = {**MIXED, "y_axis_title_secondary": "Minutes", "y_axis_title_margin": 45, + "y_axis_title_position": "Left"} + spec = _spec([titled, mixed]) + p = _params(spec) + assert (p["Revenue"]["x_axis_title_margin"], p["Revenue"]["y_axis_title_margin"], + p["Revenue"]["y_axis_title_position"]) == (15, 45, "Left") + pm = p[MIXED["name"]] + assert (pm["yAxisTitleSecondary"], pm["y_axis_title_margin"], pm["y_axis_title_position"]) == ( + "Minutes", 45, "Left") + _round_trips(spec) + _contract_clean({"echarts_timeseries_line": set(p["Revenue"]), "mixed_timeseries": set(pm)}) + plan = _plan(spec, _spec([{**titled, "y_axis_title_position": "Top"}, mixed]), monkeypatch) + assert plan["charts_changed"] == ["Revenue"] + + +def test_written_tool_spacing_builds_and_plans_as_unset(monkeypatch): + titled = {**LINE, "x_axis_title": "Month", "y_axis_title": "USD"} + written = _spec([{**titled, "x_axis_title_margin": 30, "y_axis_title_margin": 15, + "y_axis_title_position": "Top"}]) + assert _compiled(written) == _compiled(_spec([titled])) + assert _plan(written, _spec([titled]), monkeypatch)["clean"] + bar = {"name": "By region", "type": "bar", "dataset": DS, "x_column": "region", + "metrics": ["SUM(revenue)"], "orientation": "horizontal", "y_axis_title": "USD"} + assert _plan(_spec([{**bar, "y_axis_title_position": "Left"}]), _spec([bar]), monkeypatch)["clean"] + + +def test_title_spacing_needs_its_title(): + for extra in ({"x_axis_title_margin": 20}, {"y_axis_title_margin": 20}, + {"y_axis_title_position": "Left"}): + with pytest.raises(ValidationError, match="needs"): + _spec([{**LINE, **extra}]) + with pytest.raises(ValidationError): + _spec([{**LINE, "y_axis_title": "USD", "y_axis_title_position": "Right"}]) + + +def test_ui_title_spacing_reads_back_beside_its_title(): + spec = _spec([{**LINE, "y_axis_title": "USD"}]) + result = _decompile(spec, _edit_chart("Revenue", y_axis_title_position="Left", y_axis_title_margin=15)) + assert result.losses == [], result.losses_json() + back = result.spec["charts"][0] + assert back["y_axis_title_position"] == "Left" and "y_axis_title_margin" not in back + # without a title they do nothing, and read as nothing + result = _decompile(_spec([LINE]), _edit_chart("Revenue", x_axis_title_margin=50, + y_axis_title_position="Left")) + assert result.losses == [], result.losses_json() + assert not {"x_axis_title_margin", "y_axis_title_position"} & set(result.spec["charts"][0]) + result = _decompile(spec, _edit_chart("Revenue", y_axis_title_margin="wide")) + assert [loss.what for loss in result.losses] == ["y_axis_title_margin 'wide' not representable; dropped"] + + +def test_cleared_title_spacing_beside_its_title_is_a_named_loss(): + """A margin or position stored empty beside its title has no spec form, and apply + writes the tool's own: decompile names it, so adopt lists it before the first apply.""" + spec = _spec([{**LINE, "x_axis_title": "Month", "y_axis_title": "USD"}]) + result = _decompile(spec, _edit_chart("Revenue", x_axis_title_margin=None, y_axis_title_position="")) + assert sorted(loss.what for loss in result.losses) == [ + "x_axis_title_margin None not preserved (30 on re-apply)", + "y_axis_title_position '' not preserved ('Top' on re-apply)"] + + +# -- named markdown blocks ------------------------------------------------------------- + +NOTE = {"markdown": "Figures refresh nightly.", "width": 12, "height": 1} + + +def _markdown_nodes(spec) -> dict: + return {k: n for k, n in _dashboard(spec)["position"].items() + if isinstance(n, dict) and n.get("type") == "MARKDOWN"} + + +def test_named_markdown_block_gets_its_component_id(monkeypatch): + """The component id is the block's DOM id: Markdown/Markdown.tsx:418 at 6.1.0 + (gridComponents/Markdown.jsx:363 at 4.1.4 and 5.0.0), so css can use #MARKDOWN-.""" + layout = {"rows": [[{**NOTE, "id": "refresh-note"}], ["Orders"], ["Revenue"]], + "footer": [[{**NOTE, "markdown": "Owner: analytics", "id": "owner"}], [NOTE]]} + css = {"css": "#MARKDOWN-owner { font-size: 12px; }"} + spec = _spec(layout=layout, dashboard=css) + nodes = _markdown_nodes(spec) + assert "MARKDOWN-refresh-note" in nodes and "MARKDOWN-owner" in nodes + assert nodes["MARKDOWN-owner"]["meta"]["code"] == "Owner: analytics" + assert sum(k.startswith("MARKDOWN-sdc-") for k in nodes) == 1 # the unnamed block's positional id + assert nodes["MARKDOWN-owner"]["parents"] == ["ROOT_ID", "GRID_ID", "ROW-sdc-footer-1"] + result = _round_trips(spec) + assert result.spec["layout"]["rows"][0][0]["id"] == "refresh-note" + assert "id" not in result.spec["layout"]["footer"][1][0] + renamed = {**layout, "rows": [[{**NOTE, "id": "nightly-note"}], ["Orders"], ["Revenue"]]} + assert _plan(spec, _spec(layout=renamed, dashboard=css), monkeypatch)["layout_changed"] + + +def test_named_markdown_block_in_a_sketch(monkeypatch): + layout = {"sketch": ["KKKK LLLLLLLL", "nnnnnnnnnnnn"], + "legend": {"K": "Orders", "L": "Revenue", "n": {"markdown": NOTE["markdown"], "id": "note"}}} + spec = _spec(layout=layout) + assert list(_markdown_nodes(spec)) == ["MARKDOWN-note"] + _round_trips(spec) + assert _plan(spec, spec, monkeypatch)["clean"] + unnamed = {**layout, "legend": {**layout["legend"], "n": {"markdown": NOTE["markdown"]}}} + assert _plan(spec, _spec(layout=unnamed), monkeypatch)["layout_changed"] + + +def test_named_markdown_block_validation(): + def rows(*blocks): + return {"rows": [list(blocks), ["Orders"], ["Revenue"]]} + + for bad in ("Refresh", "1note", "note_1", "a" * 49): + with pytest.raises(ValidationError): + _spec(layout=rows({**NOTE, "id": bad})) + with pytest.raises(ValidationError, match="'sdc-' prefix is reserved"): + _spec(layout=rows({**NOTE, "id": "sdc-note"})) + with pytest.raises(ValidationError, match=r"duplicate markdown ids \['note'\]"): + _spec(layout={**rows({**NOTE, "id": "note"}), "footer": [[{**NOTE, "id": "note"}]]}) + with pytest.raises(ValidationError, match=r"duplicate markdown ids \['note'\]"): + _spec(layout={"tabs": [{"title": "A", "rows": [[{**NOTE, "id": "note"}], ["Orders"]]}, + {"title": "B", "sketch": ["RRRR nnnn"], "legend": { + "R": "Revenue", "n": {"markdown": "x", "id": "note"}}}]}) + + +def test_ui_minted_markdown_ids_are_not_names(): + """The UI mints `MARKDOWN-${nanoid()}` (dashboard/util/newComponentFactory.ts:74 at 6.1.0): + mixed case and '_', which no spec id can be, so such a block decompiles without an id.""" + spec = _spec(layout={"rows": [[{**NOTE, "id": "refresh-note"}], ["Orders"], ["Revenue"]]}) + bundle = _compiled(spec) + src, out = zipfile.ZipFile(io.BytesIO(bundle)), io.BytesIO() + with zipfile.ZipFile(out, "w") as dst: + for n in src.namelist(): + dst.writestr(n, src.read(n).replace(b"MARKDOWN-refresh-note", b"MARKDOWN-V1StGXR8_Z5jdHi6B-myT")) + block = decompile_bundle(out.getvalue(), _lookup(spec)).spec["layout"]["rows"][0][0] + assert "id" not in block and block["markdown"] == NOTE["markdown"] + + +# -- markdown links to a tab ------------------------------------------------------------- + +PIE = {"name": "By segment", "type": "pie", "dataset": DS, "metric": "SUM(revenue)", "groupby": "segment"} +TABLE = {"name": "Top customers", "type": "table", "dataset": DS, "groupby": ["customer"], + "metrics": ["SUM(revenue)"]} +LINKS = "[the trend](tab:Trend) · [customers](tab:Customers) · [the mix](tab:Sales/Mix)" + + +def _with_links(markdown=LINKS, sketch=False): + customers = ({"title": "Customers", "sketch": ["TTTTTTTT nnnn"], + "legend": {"T": "Top customers", "n": {"markdown": markdown}}} if sketch + else {"title": "Customers", "rows": [["Top customers"]]}) + return _spec([LINE, PIE, TABLE], layout={ + "tabs": [{"title": "Sales", "tabs": [{"title": "Trend", "rows": [["Revenue"]]}, + {"title": "Mix", "rows": [["By segment"]]}]}, + customers], + **({} if sketch else {"footer": [[{"markdown": markdown, "width": 12, "height": 1}]]})}) + + +def _tab_ids_by_title(dash) -> dict: + return {n["meta"]["text"]: k for k, n in dash["position"].items() + if isinstance(n, dict) and n.get("type") == "TAB"} + + +def _codes(dash) -> list[str]: + return [n["meta"]["code"] for n in dash["position"].values() + if isinstance(n, dict) and n.get("type") == "MARKDOWN"] + + +def test_tab_links_compile_to_the_dashboard_url_with_the_tab_in_the_hash(): + """Superset opens the tab named in the URL hash on load: getLocationHash (util/ + getLocationHash.ts:20) -> directPathToChild (actions/hydrate.ts:287-294 at 6.1.0, + hydrate.js:222-226 at 4.1.4 / 5.0.0) -> each Tabs starts on the child on that path + (gridComponents/Tabs/Tabs.tsx:135-140; Tabs.jsx:190-193 / :139-142).""" + for sketch in (False, True): + dash = _dashboard(_with_links(sketch=sketch)) + tab = _tab_ids_by_title(dash) + assert _codes(dash) == [f"[the trend](/superset/dashboard/sales/#{tab['Trend']}) · " + f"[customers](/superset/dashboard/sales/#{tab['Customers']}) · " + f"[the mix](/superset/dashboard/sales/#{tab['Mix']})"] + assert dash["position"][tab["Trend"]]["parents"][-1].startswith("TABS-") # a sub-tab + assert _codes(_dashboard(_with_links("[plain](https://example.com)"))) == ["[plain](https://example.com)"] + + +def test_tab_links_round_trip_and_plan(monkeypatch): + spec = _with_links() + result = _round_trips(spec) # tab:Trend and its canonical tab:Sales/Trend open the same tab + assert result.spec["layout"]["footer"][0][0]["markdown"] == ( + "[the trend](tab:Sales/Trend) · [customers](tab:Customers) · [the mix](tab:Sales/Mix)") + assert _plan(spec, spec, monkeypatch)["clean"] + moved = _with_links(LINKS.replace("tab:Trend", "tab:Customers")) + assert _plan(spec, moved, monkeypatch)["layout_changed"] + + +def test_tab_link_validation(): + with pytest.raises(ValidationError, match=r"no such tab; link one of \['Sales', 'Sales/Trend'"): + _with_links("[x](tab:Nope)") + + def ambiguous(md): # two sub-tabs titled Trend: the bare title is ambiguous, Parent/Child is not + return _spec([LINE, PIE], layout={"tabs": [ + {"title": "North", "tabs": [{"title": "Trend", "rows": [["Revenue"]]}]}, + {"title": "South", "tabs": [{"title": "Trend", "rows": [["By segment"]]}]}], + "footer": [[{"markdown": md}]]}) + + with pytest.raises(ValidationError, match="is ambiguous: 2 sub-tabs .* write tab:Parent/Child"): + ambiguous("[x](tab:Trend)") + position = _dashboard(ambiguous("[x](tab:South/Trend)"))["position"] + assert _codes({"position": position}) == ["[x](/superset/dashboard/sales/#TAB-sdc-2-1)"] + assert position["TAB-sdc-2-1"]["meta"]["text"] == "Trend" and position["TAB-sdc-2"]["meta"]["text"] == "South" + with pytest.raises(ValidationError, match=r"no such tab; link one of \[\]"): + _spec(layout={"rows": [[{"markdown": "[x](tab:Sales)"}], ["Orders"], ["Revenue"]]}) + + +def test_tab_titles_with_parentheses_link_and_round_trip(): + footer = lambda md: [[{"markdown": md, "width": 12, "height": 1}]] # noqa: E731 + tabs = [{"title": "Sales (EU)", "rows": [["Revenue"]]}, {"title": "Q1)", "rows": [["By segment"]]}] + spec = _spec([LINE, PIE], layout={"tabs": tabs, "footer": footer("[eu](tab:Sales (EU))")}) + assert _codes(_dashboard(spec)) == ["[eu](/superset/dashboard/sales/#TAB-sdc-1)"] + assert _round_trips(spec).spec["layout"]["footer"][0][0]["markdown"] == "[eu](tab:Sales (EU))" + # a title no link target can carry: its link reads back as the URL it is + raw = "[q1](/superset/dashboard/sales/#TAB-sdc-2)" + result = _round_trips(_spec([LINE, PIE], layout={"tabs": tabs, "footer": footer(raw)})) + assert result.spec["layout"]["footer"][0][0]["markdown"] == raw + + +def test_a_top_tab_titled_like_a_sub_tabs_path(): + """tab:Sales/Trend names the top tab 'Sales/Trend' and the sub-tab Sales > Trend: an + error to write, and decompile leaves links to either as URLs rather than pick one.""" + raw = "[sub](/superset/dashboard/sales/#TAB-sdc-1-1) [top](/superset/dashboard/sales/#TAB-sdc-2)" + + def spec(md): + return _spec([LINE, PIE], layout={ + "tabs": [{"title": "Sales", "tabs": [{"title": "Trend", "rows": [["Revenue"]]}]}, + {"title": "Sales/Trend", "rows": [["By segment"]]}], + "footer": [[{"markdown": md, "width": 12, "height": 1}]]}) + + with pytest.raises(ValidationError, match="a top tab and a sub-tab both answer to it"): + spec("[x](tab:Sales/Trend)") + assert _round_trips(spec(raw)).spec["layout"]["footer"][0][0]["markdown"] == raw + + +def test_links_to_other_dashboards_stay_as_written(): + other = "[elsewhere](/superset/dashboard/other/#TAB-sdc-1)" + result = _round_trips(_with_links(other)) + assert result.spec["layout"]["footer"][0][0]["markdown"] == other + + +# -- per-chart cross-filter scope ------------------------------------------------------ + +BAR = {"name": "By region", "type": "bar", "dataset": DS, "x_column": "region", "metrics": ["SUM(revenue)"]} + + +def _tabbed(line_scope="tab", pie_scope=("Top customers",), cross_filters=True): + """Sales > (Trend: line + pie, Mix: bar); Customers: table. The line sits in a sub-tab.""" + charts = [{**LINE, "cross_filter_scope": line_scope}, + {**PIE, "cross_filter_scope": list(pie_scope) or "global"}, BAR, TABLE] + return _spec(charts, dashboard={"cross_filters": cross_filters}, layout=TABBED) + + +TABBED = {"tabs": [ + {"title": "Sales", "tabs": [{"title": "Trend", "rows": [["Revenue", "By segment"]]}, + {"title": "Mix", "rows": [["By region"]]}]}, + {"title": "Customers", "rows": [["Top customers"]]}]} + + +def _chart_ids(dash: dict) -> dict: + return {n["meta"]["sliceName"]: n["meta"]["chartId"] for n in dash["position"].values() + if isinstance(n, dict) and n.get("type") == "CHART"} + + +def test_cross_filter_scope_compiles_to_chart_configuration(): + """ChartConfiguration (dashboard/types.ts:87-106 at 6.1.0): keyed by chart id, scope + {rootPath, excluded} or 'global'; a chart's own id is excluded (ScopingModal.tsx:240-246); + in-scope charts are those under rootPath and not excluded (util/getChartIdsInFilterScope.ts).""" + dash = _dashboard(_tabbed()) + cid = _chart_ids(dash) + trend_tab = next(k for k, n in dash["position"].items() + if isinstance(n, dict) and n.get("type") == "TAB" and n["meta"]["text"] == "Trend") + assert dash["metadata"]["chart_configuration"] == { + str(cid["Revenue"]): {"id": cid["Revenue"], "crossFilters": { + "scope": {"rootPath": [trend_tab], "excluded": [cid["Revenue"]]}, # the innermost tab + "chartsInScope": [cid["By segment"]]}}, + str(cid["By segment"]): {"id": cid["By segment"], "crossFilters": { + "scope": {"rootPath": ["ROOT_ID"], + "excluded": sorted([cid["Revenue"], cid["By segment"], cid["By region"]])}, + "chartsInScope": [cid["Top customers"]]}}, + } + unscoped = _tabbed("global", ()) + assert "chart_configuration" not in _dashboard(unscoped)["metadata"] + # a written "global" builds like an omitted scope + omitted = _spec([LINE, PIE, BAR, TABLE], dashboard={"cross_filters": True}, layout=TABBED) + assert _compiled(unscoped) == _compiled(omitted) + + +def test_cross_filter_scope_round_trips_and_shows_in_plan(monkeypatch): + spec = _tabbed() + result = _round_trips(spec) + assert [c["cross_filter_scope"] for c in result.spec["charts"] if "cross_filter_scope" in c] == [ + "tab", ["Top customers"]] + p = _plan(spec, _tabbed(pie_scope=("Top customers", "By region")), monkeypatch) + assert p["charts_changed"] == ["By segment"] + assert _plan(spec, _tabbed(line_scope="global"), monkeypatch)["charts_changed"] == ["Revenue"] + reordered = _tabbed(pie_scope=("Top customers", "By region")) + assert _plan(reordered, _tabbed(pie_scope=("By region", "Top customers")), monkeypatch)["clean"] + + +def test_cross_filter_scope_none_reaches_no_chart(monkeypatch): + """6.1.0's scoping tree saves "nothing ticked" as {rootPath: [], excluded: []} + (nativeFilters/FiltersConfigModal/FiltersConfigForm/FilterScope/utils.ts:261-270); no root + reaches no chart (util/getChartIdsInFilterScope.ts:75-85). The same at 4.1.4 / 5.0.0.""" + spec = _tabbed(line_scope="none") + dash = _dashboard(spec) + cid = _chart_ids(dash) + assert dash["metadata"]["chart_configuration"][str(cid["Revenue"])] == { + "id": cid["Revenue"], "crossFilters": {"scope": {"rootPath": [], "excluded": []}, "chartsInScope": []}} + assert _round_trips(spec).spec["charts"][0]["cross_filter_scope"] == "none" + assert _plan(spec, _tabbed(), monkeypatch)["charts_changed"] == ["Revenue"] + # a UI scope that excludes every chart reads as "none" too + excluded_all = {str(cid["Revenue"]): {"id": cid["Revenue"], "crossFilters": { + "scope": {"rootPath": ["ROOT_ID"], "excluded": sorted(cid.values())}, "chartsInScope": []}}} + result = _decompile(_tabbed(line_scope="global", pie_scope=()), + _edit_metadata(chart_configuration=excluded_all)) + assert result.losses == [] and result.spec["charts"][0]["cross_filter_scope"] == "none" + + +def _live_layout(spec, own_uuids=False): + """The compiled layout as an import leaves it: live chart ids and, for a dashboard + built in the UI, its own chart uuids (returned by name).""" + dash = _dashboard(spec) + live_ids = {old: 40 + i for i, old in enumerate(sorted(_chart_ids(dash).values()))} + position = json.loads(json.dumps(dash["position"])) + uuids = {} + for n in position.values(): + if isinstance(n, dict) and n.get("type") == "CHART": + n["meta"]["chartId"] = live_ids[n["meta"]["chartId"]] + if own_uuids: + uuids[n["meta"]["sliceName"]] = f"00000000-0000-0000-0000-{len(uuids):012d}" + n["meta"]["uuid"] = uuids[n["meta"]["sliceName"]] + return dash, position, uuids + + +class _ScopeClient: + def __init__(self, metadata, position): + self.metadata, self.position, self.puts = metadata, position, [] + + def get(self, path): + return {"result": {"json_metadata": json.dumps(self.metadata), + "position_json": json.dumps(self.position)}} + + def put_json(self, path, body): + self.puts.append(json.loads(body["json_metadata"])) + return type("R", (), {"status_code": 200, "text": ""})() + + +def test_apply_rewrites_chart_configuration_with_live_ids(): + """The bundle carries placeholder ids; only the 6.1.0 importer remaps them + (commands/dashboard/importers/v1/utils.py:147-190), so apply rewrites the config + from the live layout's chart ids on every release.""" + from chartwright.apply import _apply_cross_filter_scopes + + spec = _tabbed() + dash, position, _ = _live_layout(spec) + client = _ScopeClient(dict(dash["metadata"]), position) # keyed by placeholders, as 4.1.4 imports it + assert _apply_cross_filter_scopes(spec, client, 1) == [] + config = client.puts[0]["chart_configuration"] + ids_by_name = _chart_ids({"position": position}) + assert sorted(config) == sorted(str(ids_by_name[n]) for n in ("Revenue", "By segment")) + assert config[str(ids_by_name["By segment"])]["crossFilters"]["chartsInScope"] == [ids_by_name["Top customers"]] + assert client.puts[0]["cross_filters_enabled"] is True # the rest of the metadata is kept + # nothing to write when the live config already matches, or when no chart has a scope + client = _ScopeClient({**dash["metadata"], "chart_configuration": config}, position) + assert _apply_cross_filter_scopes(spec, client, 1) == [] and client.puts == [] + assert _apply_cross_filter_scopes(_tabbed("global", ()), client, 1) == [] and client.puts == [] + + +def test_restore_puts_the_backups_own_scopes_back_under_live_ids(): + """Restore remaps the backup's own chart_configuration by chart uuid, as the 6.1.0 + importer does (commands/dashboard/importers/v1/utils.py:48-71, :146-189), chartsInScope + included. Every entry is kept as backed up: a scope over two tabs, which no spec field + writes, keeps both roots, and nothing is rebuilt from the decompiled spec.""" + from chartwright.apply import _restore_cross_filter_scopes + + spec = _tabbed() + dash, position, _ = _live_layout(spec) + old, live = _chart_ids(dash), _chart_ids({"position": position}) + tabs = sorted(k for k, n in dash["position"].items() if isinstance(n, dict) and n.get("type") == "TAB") + config = {**dash["metadata"]["chart_configuration"], + str(old["Top customers"]): {"id": old["Top customers"], "crossFilters": { + "scope": {"rootPath": tabs[:2], "excluded": [old["Top customers"]]}, + "chartsInScope": [old["Revenue"], old["By region"]]}}} + backup = edit_bundle(_compiled(spec), _edit_metadata(chart_configuration=config)) + client = _ScopeClient(dict(dash["metadata"]), position) # 4.1.4 / 5.0.0 import the old ids + assert _restore_cross_filter_scopes(backup, client, 1) == [] + restored = client.puts[0]["chart_configuration"] + assert sorted(restored) == sorted(str(live[n]) for n in ("Revenue", "By segment", "Top customers")) + assert restored[str(live["Top customers"])] == {"id": live["Top customers"], "crossFilters": { + "scope": {"rootPath": tabs[:2], "excluded": [live["Top customers"]]}, + "chartsInScope": [live["Revenue"], live["By region"]]}} + assert restored[str(live["By segment"])]["crossFilters"]["chartsInScope"] == [live["Top customers"]] + # already in place (a second restore): nothing to write + client = _ScopeClient({**dash["metadata"], "chart_configuration": restored}, position) + assert _restore_cross_filter_scopes(backup, client, 1) == [] and client.puts == [] + # a backup without scopes writes nothing + client = _ScopeClient(dict(dash["metadata"]), position) + assert _restore_cross_filter_scopes(_compiled(_tabbed("global", ())), client, 1) == [] + assert client.puts == [] + + +def test_cross_filter_scope_validation(): + with pytest.raises(ValidationError, match="needs dashboard.cross_filters"): + _tabbed(cross_filters=False) + with pytest.raises(ValidationError, match="needs dashboard.cross_filters"): + _tabbed(line_scope="none", cross_filters=False) + for kpi_scope in (["Revenue"], "none"): + with pytest.raises(ValidationError, match="emits no cross-filters"): + _spec([{**KPI, "cross_filter_scope": kpi_scope}, LINE], dashboard={"cross_filters": True}) + with pytest.raises(ValidationError, match="needs the chart placed in a tab"): + _spec([{**LINE, "cross_filter_scope": "tab"}, PIE], dashboard={"cross_filters": True}) + for bad in (["Revenue"], ["Nope"], [], ["By segment", "By segment"]): + with pytest.raises(ValidationError, match="must list other spec charts"): + _spec([{**LINE, "cross_filter_scope": bad}, PIE], dashboard={"cross_filters": True}) + + +def test_a_chart_off_the_layout_is_named_before_its_scope(): + with pytest.raises(ValidationError, match="charts not placed in layout"): + _spec([{**LINE, "cross_filter_scope": "tab"}, PIE], dashboard={"cross_filters": True}, + layout={"rows": [["By segment"]]}) + + +def _split_tabs(path, doc): + """Customers moved to a second tab strip, with a row between the two: decompile + flattens tabs it can't hold apart into rows.""" + if "/dashboards/" not in path: + return + pos = doc["position"] + grid = pos["GRID_ID"] + customers = pos[grid["children"][0]]["children"].pop() + pos["ROW-mid"] = {"type": "ROW", "id": "ROW-mid", "children": ["MARKDOWN-mid"], + "parents": ["ROOT_ID", "GRID_ID"], "meta": {"background": "BACKGROUND_TRANSPARENT"}} + pos["MARKDOWN-mid"] = {"type": "MARKDOWN", "id": "MARKDOWN-mid", "children": [], + "parents": ["ROOT_ID", "GRID_ID", "ROW-mid"], + "meta": {"code": "Between", "width": 12, "height": 50}} + pos["TABS-two"] = {"type": "TABS", "id": "TABS-two", "children": [customers], + "parents": ["ROOT_ID", "GRID_ID"], "meta": {}} + grid["children"] += ["ROW-mid", "TABS-two"] + + +def test_a_tab_scope_in_flattened_tabs_reads_as_the_charts_it_reached(): + """"tab" needs the chart in a tab of the spec; once its tab is flattened, the scope + reads as the charts it reached, and the spec decompile writes loads.""" + result = _decompile(_tabbed(), _split_tabs) + assert "mixed rows + tabs at top level; tabs flattened into rows" in [loss.what for loss in result.losses] + scopes = {c["name"]: c.get("cross_filter_scope") for c in result.spec["charts"]} + assert scopes["Revenue"] == ["By segment"] and scopes["By segment"] == ["Top customers"] + load_spec(result.spec) + + +def test_scopes_decompile_cant_carry_are_named_losses(): + cid = _chart_ids(_dashboard(_tabbed())) + # entries not shaped as Superset saves them: named, never a crash + for entry in ([], {"crossFilters": []}, + {"crossFilters": {"scope": {"rootPath": ["ROOT_ID"], "excluded": [{}]}}}, + {"crossFilters": {"scope": {"rootPath": "ROOT_ID", "excluded": []}}}): + result = _decompile(_tabbed("global", ()), + _edit_metadata(chart_configuration={str(cid["Revenue"]): entry})) + assert [loss.what.split(":")[0] for loss in result.losses] == ["cross-filter scope not preserved"] + result = _decompile(_tabbed("global", ()), _edit_metadata(chart_configuration=["not", "a", "map"])) + assert [loss.what.split(":")[0] for loss in result.losses] == ["cross-filter scopes not preserved"] + + # a layout chart no decompiled chart matches (a stale sliceName and no uuid) + def stale(path, doc): + if "/dashboards/" in path: + for n in doc["position"].values(): + if isinstance(n, dict) and n.get("type") == "CHART" and n["meta"]["sliceName"] == "Revenue": + n["meta"].pop("uuid") + n["meta"]["sliceName"] = "Old revenue" + + result = _decompile(_tabbed(), stale) + assert (f"cross-filter scope of chart 'Old revenue' (id {cid['Revenue']}) not preserved: " + f"no decompiled chart matches it") in [loss.what for loss in result.losses] + + +def test_scopes_on_a_dashboard_without_cross_filtering_are_a_named_loss(): + result = _decompile(_tabbed(), _edit_metadata(cross_filters_enabled=False)) + assert [loss.what for loss in result.losses] == ["cross-filter scopes not preserved: cross-filtering is off"] + assert all("cross_filter_scope" not in c for c in result.spec["charts"]) diff --git a/tests/test_readability.py b/tests/test_readability.py index 7e8d694..2cdece5 100644 --- a/tests/test_readability.py +++ b/tests/test_readability.py @@ -256,6 +256,14 @@ def test_a_subtitle_needs_5_units_and_a_comparison_6(): assert kpi_fixes(mk([trend(height=4)])) == {"Revenue KPI": 5} # 22 px value at 4 +def test_a_big_number_is_judged_at_its_own_text_sizes(): + # The measured px scale with the chart's share of the card: 0.2 is half of 0.4. + assert kpi_fixes(mk([total(subtitle="booked", subtitle_font_size=0.2)])) == {} # 15 px at 4 + assert kpi_fixes(mk([total(header_font_size=0.2)])) == {"Orders KPI": 6} # 22 px at 5 + # a trendline's comparison keeps 0.15, whatever its number's size + assert kpi_fixes(mk([trend(compare_lag=1, header_font_size=0.6)])) == {"Revenue KPI": 6} + + def test_a_trend_subtitle_counts_only_where_it_shows(): data = mk([trend(subtitle="Booked revenue", height=5)]) assert kpi_fixes(data) == {"Revenue KPI": 6} # 6.x draws it at 11 px diff --git a/tests/test_standards_content.py b/tests/test_standards_content.py index 04e93af..99e5e10 100644 --- a/tests/test_standards_content.py +++ b/tests/test_standards_content.py @@ -1635,3 +1635,11 @@ def test_explain_says_the_body_already_holds_it(repo, capsys): rows = C.explain_rows(std(repo, "org"), load_spec(read(path))) row = next(r for r in rows if r["item"] == "layout.footer[org][0]") assert "already hold" in row["pending"] and "--claim" in row["pending"] + + +def test_a_rows_hash_counts_a_markdown_blocks_id(): + """A named block's id is its CSS target (MARKDOWN-), so a standard's row with an + id isn't matched by the same text without it, or under another id.""" + plain = {"markdown": "Owner: analytics", "width": 12, "height": 1} + assert C.row_hash([plain]) != C.row_hash([{**plain, "id": "owner"}]) + assert C.row_hash([{**plain, "id": "owner"}]) != C.row_hash([{**plain, "id": "team"}]) diff --git a/tests/test_ui_added_charts.py b/tests/test_ui_added_charts.py index 403f551..4846f39 100644 --- a/tests/test_ui_added_charts.py +++ b/tests/test_ui_added_charts.py @@ -143,8 +143,9 @@ def test_restore_matches_the_backups_chart_links(monkeypatch): monkeypatch.setattr(ap, "chart_payloads_from_bundle", lambda data, ids, **k: {"u1": {"slice_name": "Orders", "params": "{}"}}) import chartwright.decompile as dec - monkeypatch.setattr(dec, "decompile_bundle", lambda data, lookup: type("D", (), {"spec": SPEC})()) + monkeypatch.setattr(dec, "decompile_bundle", lambda data, lookup: type("D", (), {"spec": SPEC, "chart_uuids": {}})()) monkeypatch.setattr(dec, "live_dataset_lookup", lambda client: None) + monkeypatch.setattr(ap, "_restore_cross_filter_scopes", lambda data, client, did: []) rep = ap.restore_bundle(b"backup-zip", "ui-chart", c) assert rep.ok and c.linked == [OURS] and c.deleted == [] assert any("backup doesn't have" in w and "'Added in UI'" in w for w in rep.warnings)