How do I author a new app on the tree?¶
Customization walkthrough — Developer / Product Owner. Building a fifth (or sixth) dashboard.
The story¶
You've read the shared schema, pointed your
production data at spec_example_transactions +
spec_example_daily_balances, and the four shipped apps (L1
Reconciliation Dashboard, L2 Flow Tracing, Investigation,
Executives) cover most of what your operations team needs. But
you have one more reporting cadence — say a board-level summary
beyond the Executives view, a fraud-team triage view, a marketing
rollup — that doesn't fit any of the four existing apps' question
shapes.
You want to build a fifth dashboard from scratch. You don't want to fork an existing app, because most of the wiring you'd inherit is the wrong shape for your question. And you don't want to drop into raw QuickSight JSON, because that's how the constants-heavy maintenance burden the tree replaced started.
The tree primitives in common/tree/ are the answer. This
walkthrough walks the Executives app — the codebase's own
greenfield example — start to finish: 4 sheets, 5 visuals on the
biggest one, 1 visual-pinned filter, no parameters, no cross-app
drills. The total app file is ~600 lines of Python; no per-app
visual builders, no constant-flooded constants.py, no manual
visual-ID bookkeeping.
The question¶
"What's the minimum I need to write to add a fifth standalone
dashboard, given that the dataset interface
(spec_example_transactions + spec_example_daily_balances emitted by
common/l2/schema.py::emit_schema) is already in place?"
Where to look¶
Five reference points:
src/quicksight_gen/apps/executives/app.py— the worked example. 4 sheet specs, 4 populator functions, 1 filter group, 1build_executives_app()entry point. Read the whole file before starting; it's the smallest end-to-end app in the codebase.src/quicksight_gen/apps/executives/datasets.py— the dataset side. Two custom-SQL datasets, twoDatasetContractdeclarations, onebuild_all_datasets()helper that registers contracts withregister_contract()as a module-import side effect.- API Reference — Tree primitives — the L1 API surface. Each typed Visual subtype, Filter wrapper, and Drill action's signature is the canonical place to look up parameter shape.
src/quicksight_gen/cli/_app_builders.py— the_generate_executives()helper. Add a sibling_generate_<myapp>()here.src/quicksight_gen/cli/json.py— thejson_applycommand body that calls each_generate_<app>()in turn; add a line for yours.src/quicksight_gen/cli/_helpers.py— theAPPStuple shared across the artifact groups; append your app slug.tests/test_executives.py— 18-test starter pack that walks the tree to assert structural invariants (sheet count, visual presence, filter scoping, CLI smoke). Mirror this shape in your app's tests.
What you'll see in the demo¶
Build your app as apps/<myapp>/ alongside the existing four. The
file layout is:
src/quicksight_gen/apps/myapp/
__init__.py # one-line docstring
app.py # everything except datasets — sheet IDs, populators, build_myapp_app()
datasets.py # build_all_datasets(), DatasetContract declarations, register_contract() calls
Three things you do not need:
- No
constants.py. Sheet IDs are inline inapp.py(URL-facing, must stay stable, ~4 lines). Internal IDs (visual_id, filter_group_id, action_id, layout element IDs) are auto-derived from tree position by the L.1.16 resolver — you never write them. - No
visuals.py/filters.py/analysis.py. The tree's typed builders (row.add_kpi(...),row.add_table(...),FilterGroup.with_numeric_range_filter(...)) replace the per-app builder modules entirely. Wiring lives inapp.pypopulator functions, one per sheet. - No
demo_data.py. Because the four shipped apps all read the same per-instance prefixed base tables, the L2 instance's seed (and any per-app overlay seed) populates your new app for free. If your app needs its own seed shape, adddemo_data.pynext todatasets.py; otherwise skip it.
The skeleton of app.py looks like:
from quicksight_gen.common.config import Config
from quicksight_gen.common.ids import SheetId
from quicksight_gen.common.tree import App, Sheet
from quicksight_gen.common.tree.filters import FilterGroup
from quicksight_gen.apps.myapp.datasets import (
DS_MYAPP_FOO,
build_all_datasets,
)
# Sheet IDs (URL-facing, stable; inline since there's no constants.py).
SHEET_MYAPP_OVERVIEW = SheetId("myapp-sheet-overview")
SHEET_MYAPP_DETAIL = SheetId("myapp-sheet-detail")
def build_myapp_app(cfg: Config) -> App:
app = App(cfg=cfg)
# Register datasets (typed Dataset nodes; visuals reference these).
datasets = build_all_datasets(cfg)
ds_foo = app.register_dataset(DS_MYAPP_FOO, datasets[0].DataSetArn)
# ... register the rest
# Pre-register sheet shells so cross-sheet drills can target them
# by Sheet object ref (not string ID) before they're populated.
overview_sheet = app.analysis.add_sheet(
sheet_id=SHEET_MYAPP_OVERVIEW, name="Overview",
title="Overview", description="...",
)
detail_sheet = app.analysis.add_sheet(
sheet_id=SHEET_MYAPP_DETAIL, name="Detail",
title="Detail", description="...",
)
# Populate each sheet (one function per sheet — keeps `app.py`
# readable as the dashboard grows).
_populate_overview(overview_sheet, ds_foo, drill_target=detail_sheet)
_populate_detail(detail_sheet, ds_foo)
# Create the dashboard mirroring the analysis.
app.create_dashboard(
dashboard_id_suffix="myapp-dashboard",
name="My App",
)
return app
def _populate_overview(sheet: Sheet, ds_foo, drill_target: Sheet) -> None:
row = sheet.layout.row(height=8)
row.add_kpi(
title="Total Foos",
subtitle="Count of all foo records.",
value=ds_foo["foo_id"].distinct_count(),
)
table = row.add_table(
title="Foo Detail",
subtitle="Click any row to drill into the Detail sheet.",
group_by=[ds_foo["foo_id"].dim(), ds_foo["foo_name"].dim()],
values=[ds_foo["amount"].sum()],
actions=[Drill(
writes=[(some_param, ds_foo["foo_id"].dim())],
name="See detail for this foo",
trigger="DATA_POINT_CLICK",
target_sheet=drill_target, # Sheet *object*, not string ID
)],
)
# Visual-pinned filter (sheet-wide also available — see filter_group docs).
fg = FilterGroup.with_numeric_range_filter(
column=ds_foo["amount"], min_value=100,
filter_group_id=FilterGroupId("fg-myapp-amount-min"),
)
fg.scope_visuals(table)
sheet.filter_groups.append(fg)
def _populate_detail(sheet: Sheet, ds_foo) -> None:
# ... same shape
pass
Datasets follow the same pattern as the four shipped apps —
DatasetContract lists the column projection; the SQL must produce
exactly those columns; register_contract() wires the contract
into the typed-Column validation that catches column-name typos at
the wiring site (loud KeyError) instead of at deploy (silent
broken visual).
What it means¶
Four properties of the tree-built app pattern that internalize once you've shipped one:
- Object refs, not string IDs. Visuals reference
Datasetnodes, not dataset identifier strings; drills referenceSheetnodes, not sheet IDs.app.emit_analysis()runs validation walks (dataset / calc-field / parameter / drill-destination references) — a missing reference fails at construction with a stack trace pointing at the wiring site, not at deploy with an opaque "InvalidParameterValue". - Pre-register all sheets. Cross-sheet drills need their target
Sheetref to exist before the source visual is constructed. The pattern: declare every sheet shell first (app.analysis.add_sheet(...)for each), THEN populate them one at a time with the already-resolvedSheetreferences in scope. - Sheet IDs explicit, internal IDs auto. URL-facing identifiers
(
SheetId,ParameterName) and analyst-facing identifiers (Datasetidentifier,CalcFieldname) stay explicit because they show up in URLs / DOM / analyst tooltips; internal IDs are auto-derived because they're positional and only the tree itself reads them. See L.1.16 inPLAN.mdfor the rationale. - The tree IS the source of truth. Tests walk the tree to derive
expected sets —
tests/test_executives.pyis a good template. Don't maintain a parallel hand-listed set of expected visual titles in the test fixture; the tree walks every sheet's visuals and the test asserts what the tree emits, not what someone hand-typed.
Drilling in¶
The L.1 primitives expose more than this walkthrough surfaces:
- Calculated fields:
CalcFieldfor analysis-level computed columns. Ties to oneDataset; usable across visuals.api/tree-data.md. - Parameters + parameter controls:
StringParameter/IntegerParameter/ etc. + theirControlwrappers (dropdown, slider, datetime picker). Drills can write to parameters; filters can read from them.api/tree-filters-controls.md. - Cross-app drills:
CustomActionURLOperationbuilders incommon/drill.pyfor jumping to another app's deployed dashboard with parameter values pre-set in the URL — but note the QuickSight URL parameter sync limitation before relying on it.
Next step¶
- Skim
apps/executives/app.pyend-to-end — it's the shortest reference implementation in the codebase. - Skim
tests/test_executives.pyfor the test pattern (walk the tree, assert what's emitted). - Build a minimal
app.pywith one sheet and one visual;pytest tests/test_<myapp>.py -vto confirm it builds. - Wire it into the CLI: append your app slug to the
APPStuple incli/_helpers.py, add_generate_<myapp>()tocli/_app_builders.py(mirror_generate_executives), and add one call to it incli/json.py'sjson_applybody.json applythen emits + deploys your app alongside the others. - Add e2e tests mirroring
tests/e2e/test_exec_*.pyonce a deployment exists.
Related shape¶
- How do I swap the SQL behind a dataset?
— same
DatasetContractmechanism your app's datasets use. - How do I configure the deploy for my AWS account?
— once your app slug is in the
APPStuple,json apply --executeemits + deploys it alongside the existing four every time. - How do I reskin the dashboards for my brand?
— theme presets in
common/theme.pyapply to your app the same way.