Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [Unreleased]

### Added
- A host can pass a block when it draws a dashboard, and each shown widget is drawn from that block inside the page, in the order and with the visibility the dashboard saved, rather than loading through a request of its own.

## [2.1.0] - 2026-09-30

### Added
Expand Down
18 changes: 10 additions & 8 deletions app/helpers/dash_kit/dashboard_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ def dash_kit_widget_label(config, widget_key)
config.available_widgets.dig(widget_key.to_sym, :label) || widget_key.to_s.humanize
end

def dash_kit_render_widgets(config:)
def dash_kit_render_widgets(config:, &draw)
return content_tag(:div, "", id: "dashboard-widgets") unless config

content_tag(:div, id: dash_kit_widgets_id(config)) do
dash_kit_arrangeable?(config) ? dash_kit_widget_grid(config) : dash_kit_drawn_widgets(config)
dash_kit_arrangeable?(config) ? dash_kit_widget_grid(config, &draw) : dash_kit_drawn_widgets(config, &draw)
end
end

Expand All @@ -55,25 +55,27 @@ def dash_kit_arrangeable?(config)
DashKit.editable?(config, dash_kit_current_viewer)
end

def dash_kit_widget_grid(config)
def dash_kit_widget_grid(config, &draw)
safe_join([
react_ui("dash_kit/dashboard-grid", config.layout_data.merge(base: dash_kit.dashboard_path(config), token: dash_kit_form_token)),
block_contents(config.blocks) { |block| dash_kit_block_frame(config, block) }
block_contents(config.blocks) { |block| dash_kit_block_frame(config, block, &draw) }
])
end

def dash_kit_form_token
controller.send(:form_authenticity_token) if controller.respond_to?(:form_authenticity_token, true)
end

def dash_kit_drawn_widgets(config)
def dash_kit_drawn_widgets(config, &draw)
block_layout(config.blocks, kind: config.dashboard_type.to_sym) do |block|
dash_kit_block_frame(config, block)
dash_kit_block_frame(config, block, &draw)
end
end

def dash_kit_block_frame(config, block)
return dash_kit_widget_frame(block["type"], dashboard_id: config.id) unless block["type"] == WidgetRegistry::BUILT_WIDGET.to_s
def dash_kit_block_frame(config, block, &draw)
unless block["type"] == WidgetRegistry::BUILT_WIDGET.to_s
return draw ? capture(block["type"], &draw) : dash_kit_widget_frame(block["type"], dashboard_id: config.id)
end

definition = config.widget_definitions.find_by(id: block.dig("content", "definition_id"))
definition ? dash_kit_widget_definition_frame(definition) : dash_kit_loading_skeleton
Expand Down
23 changes: 14 additions & 9 deletions lib/dash_kit/reference/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ coupling the UI to any specific data backend.
- A widget registry: dashboard types map to the widgets available on them.
- A persistence layer for user preferences (which widgets show, in what order,
with which filters) via an ActiveRecord `Dashboard` model.
- A rendering layer that lazy-loads each widget through a Turbo Frame and
refreshes on configuration change.
- A rendering layer that lazy-loads each widget through a Turbo Frame, or hands
each widget to a block the host passes so the host draws it inside the page,
and refreshes on configuration change.

A dashboard renders two kinds of widget side by side:

Expand Down Expand Up @@ -81,16 +82,19 @@ The layers, all under the `DashKit` namespace:
`dash_kit_available_sources`, `dash_kit_visualizations`, and
`dash_kit_widget_definition_frame(definition)`. `dash_kit_render_widgets`
renders the registered widgets in order, then appends one lazy Turbo Frame per
`widget_definition`.
`widget_definition`. Given a block, it calls the block with each shown
registered widget's key and draws what the block returns in that widget's
place, inside the page's own request and with no Turbo Frame.

### Data flow

UI events -> dashboard update -> Turbo refresh -> widgets re-render via
lazy-loaded Turbo Frames. A widget is never rendered inline; it loads through a
lazy-loaded Turbo Frames. Without a block, a widget loads through a
`<turbo-frame loading="lazy">` pointing at the widget's own route. The frame
carries its dashboard's id, so the widget partial is handed the dashboard's
`filter_state` to render against (DashKit passes the blob; the host interprets
it).
it). With a block, DashKit decides only which widgets show and where, and the
host draws each one from data it has already loaded for the page.

Built widgets follow the same flow. Each `widget_definition` lazy-loads through
its own `<turbo-frame>` at `/widget_definitions/:id`;
Expand Down Expand Up @@ -255,7 +259,7 @@ The following manual steps remain:
display order.
- **Supply the data yourself.** A widget's partial is responsible for fetching
and rendering its own data; DashKit only renders the partial inside a lazy
Turbo Frame. Keep partials self-contained around the owner DashKit scopes to.
Turbo Frame, or calls the host's block with the widget's key. Keep partials self-contained around the owner DashKit scopes to.
This holds for registered widgets and for built-widget renderer partials
alike — both receive `filter_state` and read it to shape their own request.
- **Register a renderer for every visualization the builder offers.** A built
Expand All @@ -274,9 +278,10 @@ The following manual steps remain:
`WidgetManagement` methods (`toggle_widget`, `move_widget_up`,
`move_widget_down`, `update_filter`) or the engine routes; do not write
`widget_order` / `hidden_widgets` / `filter_state` directly.
- **Let widgets lazy-load.** Render through `dash_kit_render_widgets` /
`dash_kit_widget_frame` so each widget loads in its own Turbo Frame; do not
inline widget bodies.
- **Draw widgets through `dash_kit_render_widgets`.** Without a block, each
widget loads in its own Turbo Frame. Pass a block when the page already holds
the widgets' data, so every widget is drawn in the page's own request rather
than one request per widget.
- **Mobile-first, Tailwind, dark mode.** The shipped UI is styled with Tailwind
and supports dark mode; host styling should match. The primary surface is
often a native webview.
Expand Down
23 changes: 14 additions & 9 deletions lib/dash_kit/the_local/agents/dash_kit-develop.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ coupling the UI to any specific data backend.
- A widget registry: dashboard types map to the widgets available on them.
- A persistence layer for user preferences (which widgets show, in what order,
with which filters) via an ActiveRecord `Dashboard` model.
- A rendering layer that lazy-loads each widget through a Turbo Frame and
refreshes on configuration change.
- A rendering layer that lazy-loads each widget through a Turbo Frame, or hands
each widget to a block the host passes so the host draws it inside the page,
and refreshes on configuration change.

A dashboard renders two kinds of widget side by side:

Expand Down Expand Up @@ -89,16 +90,19 @@ The layers, all under the `DashKit` namespace:
`dash_kit_available_sources`, `dash_kit_visualizations`, and
`dash_kit_widget_definition_frame(definition)`. `dash_kit_render_widgets`
renders the registered widgets in order, then appends one lazy Turbo Frame per
`widget_definition`.
`widget_definition`. Given a block, it calls the block with each shown
registered widget's key and draws what the block returns in that widget's
place, inside the page's own request and with no Turbo Frame.

### Data flow

UI events -> dashboard update -> Turbo refresh -> widgets re-render via
lazy-loaded Turbo Frames. A widget is never rendered inline; it loads through a
lazy-loaded Turbo Frames. Without a block, a widget loads through a
`<turbo-frame loading="lazy">` pointing at the widget's own route. The frame
carries its dashboard's id, so the widget partial is handed the dashboard's
`filter_state` to render against (DashKit passes the blob; the host interprets
it).
it). With a block, DashKit decides only which widgets show and where, and the
host draws each one from data it has already loaded for the page.

Built widgets follow the same flow. Each `widget_definition` lazy-loads through
its own `<turbo-frame>` at `/widget_definitions/:id`;
Expand Down Expand Up @@ -263,7 +267,7 @@ The following manual steps remain:
display order.
- **Supply the data yourself.** A widget's partial is responsible for fetching
and rendering its own data; DashKit only renders the partial inside a lazy
Turbo Frame. Keep partials self-contained around the owner DashKit scopes to.
Turbo Frame, or calls the host's block with the widget's key. Keep partials self-contained around the owner DashKit scopes to.
This holds for registered widgets and for built-widget renderer partials
alike — both receive `filter_state` and read it to shape their own request.
- **Register a renderer for every visualization the builder offers.** A built
Expand All @@ -282,9 +286,10 @@ The following manual steps remain:
`WidgetManagement` methods (`toggle_widget`, `move_widget_up`,
`move_widget_down`, `update_filter`) or the engine routes; do not write
`widget_order` / `hidden_widgets` / `filter_state` directly.
- **Let widgets lazy-load.** Render through `dash_kit_render_widgets` /
`dash_kit_widget_frame` so each widget loads in its own Turbo Frame; do not
inline widget bodies.
- **Draw widgets through `dash_kit_render_widgets`.** Without a block, each
widget loads in its own Turbo Frame. Pass a block when the page already holds
the widgets' data, so every widget is drawn in the page's own request rather
than one request per widget.
- **Mobile-first, Tailwind, dark mode.** The shipped UI is styled with Tailwind
and supports dark mode; host styling should match. The primary surface is
often a native webview.
Expand Down
23 changes: 14 additions & 9 deletions lib/dash_kit/the_local/agents/dash_kit-info.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ coupling the UI to any specific data backend.
- A widget registry: dashboard types map to the widgets available on them.
- A persistence layer for user preferences (which widgets show, in what order,
with which filters) via an ActiveRecord `Dashboard` model.
- A rendering layer that lazy-loads each widget through a Turbo Frame and
refreshes on configuration change.
- A rendering layer that lazy-loads each widget through a Turbo Frame, or hands
each widget to a block the host passes so the host draws it inside the page,
and refreshes on configuration change.

A dashboard renders two kinds of widget side by side:

Expand Down Expand Up @@ -89,16 +90,19 @@ The layers, all under the `DashKit` namespace:
`dash_kit_available_sources`, `dash_kit_visualizations`, and
`dash_kit_widget_definition_frame(definition)`. `dash_kit_render_widgets`
renders the registered widgets in order, then appends one lazy Turbo Frame per
`widget_definition`.
`widget_definition`. Given a block, it calls the block with each shown
registered widget's key and draws what the block returns in that widget's
place, inside the page's own request and with no Turbo Frame.

### Data flow

UI events -> dashboard update -> Turbo refresh -> widgets re-render via
lazy-loaded Turbo Frames. A widget is never rendered inline; it loads through a
lazy-loaded Turbo Frames. Without a block, a widget loads through a
`<turbo-frame loading="lazy">` pointing at the widget's own route. The frame
carries its dashboard's id, so the widget partial is handed the dashboard's
`filter_state` to render against (DashKit passes the blob; the host interprets
it).
it). With a block, DashKit decides only which widgets show and where, and the
host draws each one from data it has already loaded for the page.

Built widgets follow the same flow. Each `widget_definition` lazy-loads through
its own `<turbo-frame>` at `/widget_definitions/:id`;
Expand Down Expand Up @@ -263,7 +267,7 @@ The following manual steps remain:
display order.
- **Supply the data yourself.** A widget's partial is responsible for fetching
and rendering its own data; DashKit only renders the partial inside a lazy
Turbo Frame. Keep partials self-contained around the owner DashKit scopes to.
Turbo Frame, or calls the host's block with the widget's key. Keep partials self-contained around the owner DashKit scopes to.
This holds for registered widgets and for built-widget renderer partials
alike — both receive `filter_state` and read it to shape their own request.
- **Register a renderer for every visualization the builder offers.** A built
Expand All @@ -282,9 +286,10 @@ The following manual steps remain:
`WidgetManagement` methods (`toggle_widget`, `move_widget_up`,
`move_widget_down`, `update_filter`) or the engine routes; do not write
`widget_order` / `hidden_widgets` / `filter_state` directly.
- **Let widgets lazy-load.** Render through `dash_kit_render_widgets` /
`dash_kit_widget_frame` so each widget loads in its own Turbo Frame; do not
inline widget bodies.
- **Draw widgets through `dash_kit_render_widgets`.** Without a block, each
widget loads in its own Turbo Frame. Pass a block when the page already holds
the widgets' data, so every widget is drawn in the page's own request rather
than one request per widget.
- **Mobile-first, Tailwind, dark mode.** The shipped UI is styled with Tailwind
and supports dark mode; host styling should match. The primary surface is
often a native webview.
Expand Down
23 changes: 14 additions & 9 deletions lib/dash_kit/the_local/agents/dash_kit-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ coupling the UI to any specific data backend.
- A widget registry: dashboard types map to the widgets available on them.
- A persistence layer for user preferences (which widgets show, in what order,
with which filters) via an ActiveRecord `Dashboard` model.
- A rendering layer that lazy-loads each widget through a Turbo Frame and
refreshes on configuration change.
- A rendering layer that lazy-loads each widget through a Turbo Frame, or hands
each widget to a block the host passes so the host draws it inside the page,
and refreshes on configuration change.

A dashboard renders two kinds of widget side by side:

Expand Down Expand Up @@ -89,16 +90,19 @@ The layers, all under the `DashKit` namespace:
`dash_kit_available_sources`, `dash_kit_visualizations`, and
`dash_kit_widget_definition_frame(definition)`. `dash_kit_render_widgets`
renders the registered widgets in order, then appends one lazy Turbo Frame per
`widget_definition`.
`widget_definition`. Given a block, it calls the block with each shown
registered widget's key and draws what the block returns in that widget's
place, inside the page's own request and with no Turbo Frame.

### Data flow

UI events -> dashboard update -> Turbo refresh -> widgets re-render via
lazy-loaded Turbo Frames. A widget is never rendered inline; it loads through a
lazy-loaded Turbo Frames. Without a block, a widget loads through a
`<turbo-frame loading="lazy">` pointing at the widget's own route. The frame
carries its dashboard's id, so the widget partial is handed the dashboard's
`filter_state` to render against (DashKit passes the blob; the host interprets
it).
it). With a block, DashKit decides only which widgets show and where, and the
host draws each one from data it has already loaded for the page.

Built widgets follow the same flow. Each `widget_definition` lazy-loads through
its own `<turbo-frame>` at `/widget_definitions/:id`;
Expand Down Expand Up @@ -263,7 +267,7 @@ The following manual steps remain:
display order.
- **Supply the data yourself.** A widget's partial is responsible for fetching
and rendering its own data; DashKit only renders the partial inside a lazy
Turbo Frame. Keep partials self-contained around the owner DashKit scopes to.
Turbo Frame, or calls the host's block with the widget's key. Keep partials self-contained around the owner DashKit scopes to.
This holds for registered widgets and for built-widget renderer partials
alike — both receive `filter_state` and read it to shape their own request.
- **Register a renderer for every visualization the builder offers.** A built
Expand All @@ -282,9 +286,10 @@ The following manual steps remain:
`WidgetManagement` methods (`toggle_widget`, `move_widget_up`,
`move_widget_down`, `update_filter`) or the engine routes; do not write
`widget_order` / `hidden_widgets` / `filter_state` directly.
- **Let widgets lazy-load.** Render through `dash_kit_render_widgets` /
`dash_kit_widget_frame` so each widget loads in its own Turbo Frame; do not
inline widget bodies.
- **Draw widgets through `dash_kit_render_widgets`.** Without a block, each
widget loads in its own Turbo Frame. Pass a block when the page already holds
the widgets' data, so every widget is drawn in the page's own request rather
than one request per widget.
- **Mobile-first, Tailwind, dark mode.** The shipped UI is styled with Tailwind
and supports dark mode; host styling should match. The primary surface is
often a native webview.
Expand Down
20 changes: 20 additions & 0 deletions test/helpers/dash_kit/dashboard_helper_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,26 @@ def configure_viewer(viewer)
assert_match "animate-pulse", html
end

test "dash_kit_render_widgets draws a widget from the host's block inside the page" do
dashboard = DashKit::Dashboard.create!(name: "D", dashboard_type: "test_dashboard", owner: Account.create!(name: "Owner"))
dashboard.add_block(KsBlocks.registry.block_types(kind: :test_dashboard).find { |type| type.key == :stats })

html = dash_kit_render_widgets(config: dashboard) { |widget_key| content_tag(:p, "Drawn #{widget_key}") }

assert_match "<p>Drawn stats</p>", html
end

test "dash_kit_render_widgets draws a widget from the host's block inside the grid its editor arranges" do
owner = Account.create!(name: "Owner")
configure_viewer(owner)
dashboard = DashKit::Dashboard.create!(name: "D", dashboard_type: "test_dashboard", owner: owner)
dashboard.add_block(KsBlocks.registry.block_types(kind: :test_dashboard).find { |type| type.key == :stats })

html = dash_kit_render_widgets(config: dashboard) { |widget_key| content_tag(:p, "Drawn #{widget_key}") }

assert_match "<p>Drawn stats</p>", html
end

test "dash_kit_render_widgets renders a built definition frame" do
definition = @config.widget_definitions.create!(source: "revenue", visualization: "single_value")
@config.add_built_widget(definition)
Expand Down
Loading