dash_kit 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +170 -0
  4. data/Rakefile +18 -0
  5. data/app/assets/builds/dash_kit/dashboard_grid.css +1 -0
  6. data/app/assets/builds/dash_kit/dashboard_grid.js +5 -0
  7. data/app/assets/javascripts/dash_kit/index.js +2 -0
  8. data/app/assets/stylesheets/dash_kit/application.css +15 -0
  9. data/app/controllers/dash_kit/application_controller.rb +6 -0
  10. data/app/controllers/dash_kit/dashboards_controller.rb +139 -0
  11. data/app/controllers/dash_kit/widget_definitions_controller.rb +33 -0
  12. data/app/controllers/dash_kit/widgets_controller.rb +39 -0
  13. data/app/helpers/dash_kit/application_helper.rb +4 -0
  14. data/app/helpers/dash_kit/dashboard_helper.rb +130 -0
  15. data/app/javascript/dash_kit/dashboard_grid.jsx +10 -0
  16. data/app/models/concerns/dash_kit/widget_management.rb +16 -0
  17. data/app/models/dash_kit/application_record.rb +5 -0
  18. data/app/models/dash_kit/dashboard.rb +53 -0
  19. data/app/models/dash_kit/widget_definition.rb +9 -0
  20. data/app/views/dash_kit/dashboards/_widgets.html.erb +1 -0
  21. data/app/views/dash_kit/dashboards/edit.html.erb +21 -0
  22. data/app/views/dash_kit/dashboards/index.html.erb +34 -0
  23. data/app/views/dash_kit/dashboards/new.html.erb +28 -0
  24. data/app/views/dash_kit/widget_definitions/_missing_renderer.html.erb +3 -0
  25. data/app/views/layouts/dash_kit/application.html.erb +17 -0
  26. data/config/importmap.rb +1 -0
  27. data/config/routes.rb +16 -0
  28. data/lib/dash_kit/configuration_backfill.rb +27 -0
  29. data/lib/dash_kit/engine.rb +19 -0
  30. data/lib/dash_kit/reference/guide.md +289 -0
  31. data/lib/dash_kit/reference.rb +11 -0
  32. data/lib/dash_kit/renderer_registry.rb +21 -0
  33. data/lib/dash_kit/the_local/agents/dash_kit-develop.md +298 -0
  34. data/lib/dash_kit/the_local/agents/dash_kit-info.md +298 -0
  35. data/lib/dash_kit/the_local/agents/dash_kit-install.md +298 -0
  36. data/lib/dash_kit/the_local.rb +34 -0
  37. data/lib/dash_kit/version.rb +3 -0
  38. data/lib/dash_kit/widget_registry.rb +69 -0
  39. data/lib/dash_kit.rb +72 -0
  40. data/lib/generators/dash_kit/install_generator.rb +79 -0
  41. data/lib/generators/dash_kit/templates/create_dash_kit_dashboards.rb.tt +20 -0
  42. data/lib/generators/dash_kit/templates/create_dash_kit_widget_definitions.rb.tt +12 -0
  43. data/lib/generators/dash_kit/templates/dash_kit.rb.tt +16 -0
  44. data/lib/tasks/dash_kit_tasks.rake +9 -0
  45. metadata +147 -0
@@ -0,0 +1,289 @@
1
+ ## DashKit
2
+
3
+ DashKit is a Rails engine gem for composable, configurable dashboards. It is a
4
+ presentation and interaction layer: you register widgets, persist per-owner
5
+ configuration (visibility, order, filters), and render dashboards without
6
+ coupling the UI to any specific data backend.
7
+
8
+ ### What DashKit is
9
+
10
+ - A Rails engine (`DashKit::Engine`) with an isolated namespace, mounted in the
11
+ host app.
12
+ - A widget registry: dashboard types map to the widgets available on them.
13
+ - A persistence layer for user preferences (which widgets show, in what order,
14
+ with which filters) via an ActiveRecord `Dashboard` model.
15
+ - A rendering layer that lazy-loads each widget through a Turbo Frame and
16
+ refreshes on configuration change.
17
+
18
+ A dashboard renders two kinds of widget side by side:
19
+
20
+ - **Registered widgets** — declared up front in `DashKit.configure`. The
21
+ developer fixes the set of widgets a dashboard type can show.
22
+ - **Widget definitions (built widgets)** — created at runtime by a host-app
23
+ *user* through the settings modal and persisted as records. The host opens a
24
+ small builder UI (a `source` and a `visualization`) and DashKit renders each
25
+ built widget through a host-registered renderer partial.
26
+
27
+ ### What DashKit is not
28
+
29
+ - It does **not** define metrics or their meaning.
30
+ - It does **not** query databases or fetch data. Widgets declare what they need
31
+ through a partial; the host app supplies the data.
32
+ - It does **not** validate filter meaning or enforce business rules.
33
+ - It assumes no specific backend or analytics engine.
34
+
35
+ DashKit treats datasets as opaque structures it can render consistently, so a
36
+ host can replace or evolve its backend without rewriting dashboards.
37
+
38
+ ### Architecture
39
+
40
+ The layers, all under the `DashKit` namespace:
41
+
42
+ - **WidgetRegistry** (`lib/dash_kit/widget_registry.rb`) — a global registry
43
+ mapping a dashboard type to its available widgets. `DashKit.registry` returns
44
+ the singleton; `DashKit.configure { |r| ... }` yields it. Each
45
+ `register(:type)` block uses a `DashboardBuilder` whose `widget(key, label:,
46
+ partial:, **options)` declares one widget and assigns it a position by
47
+ declaration order.
48
+ - **Dashboard** (`app/models/dash_kit/dashboard.rb`) — the ActiveRecord model
49
+ (table `dash_kit_dashboards`) persisting per-owner configuration:
50
+ `widget_order`, `hidden_widgets`, `widget_settings`, and `filter_state`, plus
51
+ `name`, `dashboard_type`, `visibility` (`private` or `account`),
52
+ `role_default_for`, and `active`. It `belongs_to :owner, polymorphic: true`
53
+ and optionally an `account`. Owners may have many named dashboards;
54
+ `for_owner(owner)` and `for_account(account)` scope them, and `activate!` /
55
+ `duplicate!` manage the active one.
56
+ - **WidgetManagement** (`app/models/concerns/dash_kit/widget_management.rb`) — a
57
+ concern mixed into `Dashboard` providing `ordered_visible_widgets`,
58
+ `available_widgets`, `widget_visible?`, `toggle_widget`, `move_widget_up`,
59
+ `move_widget_down`, and `update_filter`.
60
+ - **WidgetDefinition** (`app/models/dash_kit/widget_definition.rb`) — the
61
+ ActiveRecord model (table `dash_kit_widget_definitions`) for a widget a user
62
+ built at runtime. It `belongs_to :dashboard` and carries `source` (string),
63
+ `visualization` (string), and `options` (jsonb, default `{}`). `Dashboard
64
+ has_many :widget_definitions, -> { order(:id) }, dependent: :destroy`.
65
+ - **RendererRegistry** (`lib/dash_kit/renderer_registry.rb`) — a global registry
66
+ mapping a visualization name to the host partial that draws it.
67
+ `DashKit.register_renderer(:visualization_name, partial: "path/to/partial")`
68
+ registers one; `DashKit.renderer_for(visualization)` looks it up;
69
+ `DashKit.visualizations` lists the registered names; `DashKit.reset_renderers!`
70
+ clears them. A built widget whose visualization has no registered renderer
71
+ falls back to the `dash_kit/widget_definitions/missing_renderer` partial.
72
+ - **Sources** — the host declares what a viewer may build from with
73
+ `DashKit.available_sources_for = ->(viewer) { [...] }`, a lambda returning the
74
+ list of sources offered in the builder. `DashKit.available_sources(viewer)`
75
+ calls it. It defaults to `NO_SOURCES` (`->(_viewer) { [] }`), so the builder UI
76
+ stays hidden until the host sets it.
77
+ - **DashboardHelper** (`app/helpers/dash_kit/dashboard_helper.rb`) — view
78
+ helpers: `dash_kit_render_widgets(config:)`, `dash_kit_widget_frame`,
79
+ `dash_kit_settings_modal(config:)`, `dash_kit_settings_button_attributes`,
80
+ `dash_kit_loading_skeleton`, and, for built widgets,
81
+ `dash_kit_available_sources`, `dash_kit_visualizations`, and
82
+ `dash_kit_widget_definition_frame(definition)`. `dash_kit_render_widgets`
83
+ renders the registered widgets in order, then appends one lazy Turbo Frame per
84
+ `widget_definition`.
85
+
86
+ ### Data flow
87
+
88
+ UI events -> dashboard update -> Turbo refresh -> widgets re-render via
89
+ lazy-loaded Turbo Frames. A widget is never rendered inline; it loads through a
90
+ `<turbo-frame loading="lazy">` pointing at the widget's own route. The frame
91
+ carries its dashboard's id, so the widget partial is handed the dashboard's
92
+ `filter_state` to render against (DashKit passes the blob; the host interprets
93
+ it).
94
+
95
+ Built widgets follow the same flow. Each `widget_definition` lazy-loads through
96
+ its own `<turbo-frame>` at `/widget_definitions/:id`;
97
+ `WidgetDefinitionsController#show` finds the definition scoped to the current
98
+ owner's dashboards, looks up `renderer_for(definition.visualization)`, and
99
+ renders that host partial with `source:` (the definition's source), `options:`
100
+ (its options hash), and `filter_state: definition.dashboard.filter_state`. So
101
+ **both** registered widgets and built widgets receive the dashboard's
102
+ `filter_state` as a local — DashKit passes the opaque blob and the host partial
103
+ reads it to shape its own data or stats request.
104
+
105
+ ### Routes (mounted at `/dash_kit`)
106
+
107
+ The engine routes live in `config/routes.rb`:
108
+
109
+ - `GET /widgets/:id` — render an individual registered widget
110
+ - `GET /widget_definitions/:id` — render an individual built widget
111
+ - `resources :dashboards` (`index`, `new`, `create`, `edit`, `update`,
112
+ `destroy`) plus member `select`, `duplicate`, `toggle_widget`, `move_widget`,
113
+ `reorder`, and `save_filters` — the dashboard CRUD plus the widget
114
+ visibility/order/filter actions.
115
+ - Dashboard member `create_definition`, `update_definition`, and
116
+ `destroy_definition` — create, update, and delete a built widget. They accept
117
+ `widget_definition[source, visualization, options]` (strong params
118
+ `permit(:source, :visualization, options: {})`) and are guarded by
119
+ `require_editable!`.
120
+
121
+ ### JavaScript
122
+
123
+ Stimulus controllers ship via importmap (no Node.js build step). The
124
+ `SortableListController` handles client-side widget toggles and drag reordering,
125
+ batching saves when the settings modal closes. It depends on `sortablejs`.
126
+
127
+ ### Widget builder (built widgets)
128
+
129
+ Beyond the registered widgets a developer declares, DashKit lets a host-app
130
+ *user* build their own widgets at runtime and persist them as
131
+ `WidgetDefinition` records. Two host-supplied pieces turn the builder on:
132
+
133
+ - **Sources** — set `DashKit.available_sources_for = ->(viewer) { [...] }` to
134
+ return the list of sources a viewer may build from. Until the host sets this
135
+ it defaults to `NO_SOURCES` and the builder UI stays hidden. The settings
136
+ modal only renders the "Build a widget" form when
137
+ `dash_kit_available_sources.any?`.
138
+ - **Renderers** — call `DashKit.register_renderer(:visualization, partial:
139
+ "path/to/partial")` for each visualization the builder offers, and write each
140
+ renderer partial. A built widget whose visualization has no registered
141
+ renderer falls back to `dash_kit/widget_definitions/missing_renderer`.
142
+
143
+ In the UI, the settings modal's "Build a widget" form is a `source` select
144
+ (from `available_sources`) and a `visualization` select (from `visualizations`);
145
+ it POSTs to `create_definition`. Once saved, `dash_kit_render_widgets` appends a
146
+ lazy Turbo Frame for the new definition, which loads `/widget_definitions/:id`
147
+ and renders the matching renderer partial with `source`, `options`, and
148
+ `filter_state` — the same `filter_state` contract registered widgets get. A
149
+ renderer partial reads `filter_state` to shape its own data request; DashKit
150
+ passes the opaque blob and never interprets it.
151
+
152
+ ### Installation in a host app
153
+
154
+ Add the gem to the host's `Gemfile` (until it is on RubyGems, use a git source):
155
+
156
+ ```ruby
157
+ gem "dash_kit", github: "DYB-Development/dash_kit"
158
+ ```
159
+
160
+ Then run the install generator and migrate:
161
+
162
+ ```bash
163
+ bundle install
164
+ rails generate dash_kit:install
165
+ rails db:migrate
166
+ ```
167
+
168
+ The generator creates the migrations for `dash_kit_dashboards` and
169
+ `dash_kit_widget_definitions`, the `config/initializers/dash_kit.rb`
170
+ initializer, and mounts the engine with `mount DashKit::Engine => "/dash_kit"`.
171
+ The following manual steps remain:
172
+
173
+ 1. **Add the association to the owner model** (the model that owns dashboards,
174
+ e.g. `Account`):
175
+
176
+ ```ruby
177
+ has_many :dash_kit_dashboards, class_name: "DashKit::Dashboard",
178
+ as: :owner, dependent: :destroy
179
+ ```
180
+
181
+ 2. **Pin sortablejs** in `config/importmap.rb`:
182
+
183
+ ```ruby
184
+ pin "sortablejs"
185
+ ```
186
+
187
+ 3. **Register the Stimulus controllers** in
188
+ `app/javascript/controllers/index.js`:
189
+
190
+ ```js
191
+ import { registerControllers as registerDashKitControllers } from "dash_kit/index"
192
+ registerDashKitControllers(application)
193
+ ```
194
+
195
+ 4. **Configure the initializer** `config/initializers/dash_kit.rb`:
196
+
197
+ ```ruby
198
+ DashKit.parent_controller = "ApplicationController"
199
+ DashKit.current_owner_method = :current_account
200
+
201
+ DashKit.configure do |config|
202
+ config.register(:home) do |d|
203
+ d.widget :on_deck, label: "On Deck", partial: "widgets/home/on_deck"
204
+ d.widget :tasks, label: "Tasks", partial: "widgets/home/tasks"
205
+ end
206
+ end
207
+ ```
208
+
209
+ - `parent_controller` is the controller DashKit's controllers inherit from,
210
+ giving them the host's authentication and helpers. It defaults to
211
+ `DashKit::ApplicationController`.
212
+ - `current_owner_method` is the method DashKit calls to scope dashboards
213
+ to the current owner (e.g. `:current_account`). It defaults to `nil` and
214
+ must be set.
215
+
216
+ 5. **Create a dashboard controller and view** that look up the dashboard and
217
+ render the widgets:
218
+
219
+ ```ruby
220
+ class DashboardController < ApplicationController
221
+ def show
222
+ @dashboard = DashKit::Dashboard.for_owner(current_account)
223
+ .find_or_create_by!(dashboard_type: "home", name: "Home") do |dashboard|
224
+ dashboard.widget_order = DashKit.registry.default_widget_order(:home)
225
+ end
226
+ end
227
+ end
228
+ ```
229
+
230
+ ```erb
231
+ <%= dash_kit_settings_button_attributes %>
232
+ <%= dash_kit_settings_modal(config: @dashboard) %>
233
+ <%= dash_kit_render_widgets(config: @dashboard) %>
234
+ ```
235
+
236
+ 6. **(Optional) Enable the widget builder.** To let users build their own
237
+ widgets, set the sources lambda and register a renderer per visualization in
238
+ the initializer, then write each renderer partial:
239
+
240
+ ```ruby
241
+ DashKit.available_sources_for = ->(viewer) { viewer.account.reports }
242
+ DashKit.register_renderer(:bar_chart, partial: "widgets/renderers/bar_chart")
243
+ DashKit.register_renderer(:stat, partial: "widgets/renderers/stat")
244
+ ```
245
+
246
+ Each renderer partial receives `source`, `options`, and `filter_state` as
247
+ locals. Until `available_sources_for` returns a non-empty list the builder UI
248
+ stays hidden.
249
+
250
+ ### Conventions a consuming app must follow
251
+
252
+ - **Register every dashboard type and its widgets** in
253
+ `DashKit.configure`; a widget that is not registered will not render. Each
254
+ `widget` needs a `label` and a `partial`. Declaration order is the default
255
+ display order.
256
+ - **Supply the data yourself.** A widget's partial is responsible for fetching
257
+ and rendering its own data; DashKit only renders the partial inside a lazy
258
+ Turbo Frame. Keep partials self-contained around the owner DashKit scopes to.
259
+ This holds for registered widgets and for built-widget renderer partials
260
+ alike — both receive `filter_state` and read it to shape their own request.
261
+ - **Register a renderer for every visualization the builder offers.** A built
262
+ widget renders through the partial registered with
263
+ `DashKit.register_renderer`; one with no registered renderer falls back to the
264
+ `missing_renderer` partial. Set `DashKit.available_sources_for` to expose the
265
+ builder UI in the first place.
266
+ - **Persist built widgets through the engine routes**
267
+ (`create_definition` / `update_definition` / `destroy_definition`), not by
268
+ writing `WidgetDefinition` rows by hand.
269
+ - **Scope to the current owner.** Always look up dashboards through
270
+ `DashKit::Dashboard.for_owner(owner)` and set `current_owner_method` so
271
+ DashKit never leaks one owner's dashboard to another. `Dashboard` is
272
+ polymorphic on `owner`.
273
+ - **Persist preferences through the model, not by hand.** Use the
274
+ `WidgetManagement` methods (`toggle_widget`, `move_widget_up`,
275
+ `move_widget_down`, `update_filter`) or the engine routes; do not write
276
+ `widget_order` / `hidden_widgets` / `filter_state` directly.
277
+ - **Let widgets lazy-load.** Render through `dash_kit_render_widgets` /
278
+ `dash_kit_widget_frame` so each widget loads in its own Turbo Frame; do not
279
+ inline widget bodies.
280
+ - **Mobile-first, Tailwind, dark mode.** The shipped UI is styled with Tailwind
281
+ and supports dark mode; host styling should match. The primary surface is
282
+ often a native webview.
283
+
284
+ ### Requirements
285
+
286
+ - Ruby >= 3.2, Rails >= 7.1.
287
+ - `turbo-rails` for Turbo Frames and Stream refreshes.
288
+ - `keystone_ui` (a ViewComponent-based UI gem) for shared UI components.
289
+ - `sortablejs`, pinned via importmap, for drag reordering.
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DashKit
4
+ module Reference
5
+ GUIDE_PATH = File.expand_path("reference/guide.md", __dir__)
6
+
7
+ def self.content
8
+ File.read(GUIDE_PATH)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DashKit
4
+ class RendererRegistry
5
+ def initialize
6
+ @renderers = {}
7
+ end
8
+
9
+ def register(visualization, partial:)
10
+ @renderers[visualization.to_sym] = partial
11
+ end
12
+
13
+ def renderer_for(visualization)
14
+ @renderers[visualization.to_sym]
15
+ end
16
+
17
+ def visualizations
18
+ @renderers.keys
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,298 @@
1
+ ---
2
+ name: dash_kit-develop
3
+ description: Use to build dashboards and widgets with DashKit in a host app: register widget types, write widget partials, render and lazy-load them, and persist per-owner visibility, order, and filters correctly.
4
+ tools: Read, Edit, Write, Bash
5
+ ---
6
+
7
+ You build dashboards and widgets with DashKit, following the reference's conventions. You register dashboard types and widgets in DashKit.configure, write self-contained widget partials that fetch their own data, render through dash_kit_render_widgets so widgets lazy-load in Turbo Frames, and always scope dashboards to the current owner via Dashboard.for_owner. You enable the runtime widget builder by setting DashKit.available_sources_for and registering a renderer per visualization with DashKit.register_renderer, writing renderer partials that read filter_state just like registered widgets, and persisting built widgets through the create_definition/update_definition/destroy_definition routes rather than writing WidgetDefinition rows by hand. You persist preferences through the WidgetManagement methods and engine routes rather than writing JSON columns by hand. You never make DashKit query data or enforce metric semantics — those belong to the host.
8
+
9
+ ## DashKit
10
+
11
+ DashKit is a Rails engine gem for composable, configurable dashboards. It is a
12
+ presentation and interaction layer: you register widgets, persist per-owner
13
+ configuration (visibility, order, filters), and render dashboards without
14
+ coupling the UI to any specific data backend.
15
+
16
+ ### What DashKit is
17
+
18
+ - A Rails engine (`DashKit::Engine`) with an isolated namespace, mounted in the
19
+ host app.
20
+ - A widget registry: dashboard types map to the widgets available on them.
21
+ - A persistence layer for user preferences (which widgets show, in what order,
22
+ with which filters) via an ActiveRecord `Dashboard` model.
23
+ - A rendering layer that lazy-loads each widget through a Turbo Frame and
24
+ refreshes on configuration change.
25
+
26
+ A dashboard renders two kinds of widget side by side:
27
+
28
+ - **Registered widgets** — declared up front in `DashKit.configure`. The
29
+ developer fixes the set of widgets a dashboard type can show.
30
+ - **Widget definitions (built widgets)** — created at runtime by a host-app
31
+ *user* through the settings modal and persisted as records. The host opens a
32
+ small builder UI (a `source` and a `visualization`) and DashKit renders each
33
+ built widget through a host-registered renderer partial.
34
+
35
+ ### What DashKit is not
36
+
37
+ - It does **not** define metrics or their meaning.
38
+ - It does **not** query databases or fetch data. Widgets declare what they need
39
+ through a partial; the host app supplies the data.
40
+ - It does **not** validate filter meaning or enforce business rules.
41
+ - It assumes no specific backend or analytics engine.
42
+
43
+ DashKit treats datasets as opaque structures it can render consistently, so a
44
+ host can replace or evolve its backend without rewriting dashboards.
45
+
46
+ ### Architecture
47
+
48
+ The layers, all under the `DashKit` namespace:
49
+
50
+ - **WidgetRegistry** (`lib/dash_kit/widget_registry.rb`) — a global registry
51
+ mapping a dashboard type to its available widgets. `DashKit.registry` returns
52
+ the singleton; `DashKit.configure { |r| ... }` yields it. Each
53
+ `register(:type)` block uses a `DashboardBuilder` whose `widget(key, label:,
54
+ partial:, **options)` declares one widget and assigns it a position by
55
+ declaration order.
56
+ - **Dashboard** (`app/models/dash_kit/dashboard.rb`) — the ActiveRecord model
57
+ (table `dash_kit_dashboards`) persisting per-owner configuration:
58
+ `widget_order`, `hidden_widgets`, `widget_settings`, and `filter_state`, plus
59
+ `name`, `dashboard_type`, `visibility` (`private` or `account`),
60
+ `role_default_for`, and `active`. It `belongs_to :owner, polymorphic: true`
61
+ and optionally an `account`. Owners may have many named dashboards;
62
+ `for_owner(owner)` and `for_account(account)` scope them, and `activate!` /
63
+ `duplicate!` manage the active one.
64
+ - **WidgetManagement** (`app/models/concerns/dash_kit/widget_management.rb`) — a
65
+ concern mixed into `Dashboard` providing `ordered_visible_widgets`,
66
+ `available_widgets`, `widget_visible?`, `toggle_widget`, `move_widget_up`,
67
+ `move_widget_down`, and `update_filter`.
68
+ - **WidgetDefinition** (`app/models/dash_kit/widget_definition.rb`) — the
69
+ ActiveRecord model (table `dash_kit_widget_definitions`) for a widget a user
70
+ built at runtime. It `belongs_to :dashboard` and carries `source` (string),
71
+ `visualization` (string), and `options` (jsonb, default `{}`). `Dashboard
72
+ has_many :widget_definitions, -> { order(:id) }, dependent: :destroy`.
73
+ - **RendererRegistry** (`lib/dash_kit/renderer_registry.rb`) — a global registry
74
+ mapping a visualization name to the host partial that draws it.
75
+ `DashKit.register_renderer(:visualization_name, partial: "path/to/partial")`
76
+ registers one; `DashKit.renderer_for(visualization)` looks it up;
77
+ `DashKit.visualizations` lists the registered names; `DashKit.reset_renderers!`
78
+ clears them. A built widget whose visualization has no registered renderer
79
+ falls back to the `dash_kit/widget_definitions/missing_renderer` partial.
80
+ - **Sources** — the host declares what a viewer may build from with
81
+ `DashKit.available_sources_for = ->(viewer) { [...] }`, a lambda returning the
82
+ list of sources offered in the builder. `DashKit.available_sources(viewer)`
83
+ calls it. It defaults to `NO_SOURCES` (`->(_viewer) { [] }`), so the builder UI
84
+ stays hidden until the host sets it.
85
+ - **DashboardHelper** (`app/helpers/dash_kit/dashboard_helper.rb`) — view
86
+ helpers: `dash_kit_render_widgets(config:)`, `dash_kit_widget_frame`,
87
+ `dash_kit_settings_modal(config:)`, `dash_kit_settings_button_attributes`,
88
+ `dash_kit_loading_skeleton`, and, for built widgets,
89
+ `dash_kit_available_sources`, `dash_kit_visualizations`, and
90
+ `dash_kit_widget_definition_frame(definition)`. `dash_kit_render_widgets`
91
+ renders the registered widgets in order, then appends one lazy Turbo Frame per
92
+ `widget_definition`.
93
+
94
+ ### Data flow
95
+
96
+ UI events -> dashboard update -> Turbo refresh -> widgets re-render via
97
+ lazy-loaded Turbo Frames. A widget is never rendered inline; it loads through a
98
+ `<turbo-frame loading="lazy">` pointing at the widget's own route. The frame
99
+ carries its dashboard's id, so the widget partial is handed the dashboard's
100
+ `filter_state` to render against (DashKit passes the blob; the host interprets
101
+ it).
102
+
103
+ Built widgets follow the same flow. Each `widget_definition` lazy-loads through
104
+ its own `<turbo-frame>` at `/widget_definitions/:id`;
105
+ `WidgetDefinitionsController#show` finds the definition scoped to the current
106
+ owner's dashboards, looks up `renderer_for(definition.visualization)`, and
107
+ renders that host partial with `source:` (the definition's source), `options:`
108
+ (its options hash), and `filter_state: definition.dashboard.filter_state`. So
109
+ **both** registered widgets and built widgets receive the dashboard's
110
+ `filter_state` as a local — DashKit passes the opaque blob and the host partial
111
+ reads it to shape its own data or stats request.
112
+
113
+ ### Routes (mounted at `/dash_kit`)
114
+
115
+ The engine routes live in `config/routes.rb`:
116
+
117
+ - `GET /widgets/:id` — render an individual registered widget
118
+ - `GET /widget_definitions/:id` — render an individual built widget
119
+ - `resources :dashboards` (`index`, `new`, `create`, `edit`, `update`,
120
+ `destroy`) plus member `select`, `duplicate`, `toggle_widget`, `move_widget`,
121
+ `reorder`, and `save_filters` — the dashboard CRUD plus the widget
122
+ visibility/order/filter actions.
123
+ - Dashboard member `create_definition`, `update_definition`, and
124
+ `destroy_definition` — create, update, and delete a built widget. They accept
125
+ `widget_definition[source, visualization, options]` (strong params
126
+ `permit(:source, :visualization, options: {})`) and are guarded by
127
+ `require_editable!`.
128
+
129
+ ### JavaScript
130
+
131
+ Stimulus controllers ship via importmap (no Node.js build step). The
132
+ `SortableListController` handles client-side widget toggles and drag reordering,
133
+ batching saves when the settings modal closes. It depends on `sortablejs`.
134
+
135
+ ### Widget builder (built widgets)
136
+
137
+ Beyond the registered widgets a developer declares, DashKit lets a host-app
138
+ *user* build their own widgets at runtime and persist them as
139
+ `WidgetDefinition` records. Two host-supplied pieces turn the builder on:
140
+
141
+ - **Sources** — set `DashKit.available_sources_for = ->(viewer) { [...] }` to
142
+ return the list of sources a viewer may build from. Until the host sets this
143
+ it defaults to `NO_SOURCES` and the builder UI stays hidden. The settings
144
+ modal only renders the "Build a widget" form when
145
+ `dash_kit_available_sources.any?`.
146
+ - **Renderers** — call `DashKit.register_renderer(:visualization, partial:
147
+ "path/to/partial")` for each visualization the builder offers, and write each
148
+ renderer partial. A built widget whose visualization has no registered
149
+ renderer falls back to `dash_kit/widget_definitions/missing_renderer`.
150
+
151
+ In the UI, the settings modal's "Build a widget" form is a `source` select
152
+ (from `available_sources`) and a `visualization` select (from `visualizations`);
153
+ it POSTs to `create_definition`. Once saved, `dash_kit_render_widgets` appends a
154
+ lazy Turbo Frame for the new definition, which loads `/widget_definitions/:id`
155
+ and renders the matching renderer partial with `source`, `options`, and
156
+ `filter_state` — the same `filter_state` contract registered widgets get. A
157
+ renderer partial reads `filter_state` to shape its own data request; DashKit
158
+ passes the opaque blob and never interprets it.
159
+
160
+ ### Installation in a host app
161
+
162
+ Add the gem to the host's `Gemfile` (until it is on RubyGems, use a git source):
163
+
164
+ ```ruby
165
+ gem "dash_kit", github: "DYB-Development/dash_kit"
166
+ ```
167
+
168
+ Then run the install generator and migrate:
169
+
170
+ ```bash
171
+ bundle install
172
+ rails generate dash_kit:install
173
+ rails db:migrate
174
+ ```
175
+
176
+ The generator creates the migrations for `dash_kit_dashboards` and
177
+ `dash_kit_widget_definitions`, the `config/initializers/dash_kit.rb`
178
+ initializer, and mounts the engine with `mount DashKit::Engine => "/dash_kit"`.
179
+ The following manual steps remain:
180
+
181
+ 1. **Add the association to the owner model** (the model that owns dashboards,
182
+ e.g. `Account`):
183
+
184
+ ```ruby
185
+ has_many :dash_kit_dashboards, class_name: "DashKit::Dashboard",
186
+ as: :owner, dependent: :destroy
187
+ ```
188
+
189
+ 2. **Pin sortablejs** in `config/importmap.rb`:
190
+
191
+ ```ruby
192
+ pin "sortablejs"
193
+ ```
194
+
195
+ 3. **Register the Stimulus controllers** in
196
+ `app/javascript/controllers/index.js`:
197
+
198
+ ```js
199
+ import { registerControllers as registerDashKitControllers } from "dash_kit/index"
200
+ registerDashKitControllers(application)
201
+ ```
202
+
203
+ 4. **Configure the initializer** `config/initializers/dash_kit.rb`:
204
+
205
+ ```ruby
206
+ DashKit.parent_controller = "ApplicationController"
207
+ DashKit.current_owner_method = :current_account
208
+
209
+ DashKit.configure do |config|
210
+ config.register(:home) do |d|
211
+ d.widget :on_deck, label: "On Deck", partial: "widgets/home/on_deck"
212
+ d.widget :tasks, label: "Tasks", partial: "widgets/home/tasks"
213
+ end
214
+ end
215
+ ```
216
+
217
+ - `parent_controller` is the controller DashKit's controllers inherit from,
218
+ giving them the host's authentication and helpers. It defaults to
219
+ `DashKit::ApplicationController`.
220
+ - `current_owner_method` is the method DashKit calls to scope dashboards
221
+ to the current owner (e.g. `:current_account`). It defaults to `nil` and
222
+ must be set.
223
+
224
+ 5. **Create a dashboard controller and view** that look up the dashboard and
225
+ render the widgets:
226
+
227
+ ```ruby
228
+ class DashboardController < ApplicationController
229
+ def show
230
+ @dashboard = DashKit::Dashboard.for_owner(current_account)
231
+ .find_or_create_by!(dashboard_type: "home", name: "Home") do |dashboard|
232
+ dashboard.widget_order = DashKit.registry.default_widget_order(:home)
233
+ end
234
+ end
235
+ end
236
+ ```
237
+
238
+ ```erb
239
+ <%= dash_kit_settings_button_attributes %>
240
+ <%= dash_kit_settings_modal(config: @dashboard) %>
241
+ <%= dash_kit_render_widgets(config: @dashboard) %>
242
+ ```
243
+
244
+ 6. **(Optional) Enable the widget builder.** To let users build their own
245
+ widgets, set the sources lambda and register a renderer per visualization in
246
+ the initializer, then write each renderer partial:
247
+
248
+ ```ruby
249
+ DashKit.available_sources_for = ->(viewer) { viewer.account.reports }
250
+ DashKit.register_renderer(:bar_chart, partial: "widgets/renderers/bar_chart")
251
+ DashKit.register_renderer(:stat, partial: "widgets/renderers/stat")
252
+ ```
253
+
254
+ Each renderer partial receives `source`, `options`, and `filter_state` as
255
+ locals. Until `available_sources_for` returns a non-empty list the builder UI
256
+ stays hidden.
257
+
258
+ ### Conventions a consuming app must follow
259
+
260
+ - **Register every dashboard type and its widgets** in
261
+ `DashKit.configure`; a widget that is not registered will not render. Each
262
+ `widget` needs a `label` and a `partial`. Declaration order is the default
263
+ display order.
264
+ - **Supply the data yourself.** A widget's partial is responsible for fetching
265
+ and rendering its own data; DashKit only renders the partial inside a lazy
266
+ Turbo Frame. Keep partials self-contained around the owner DashKit scopes to.
267
+ This holds for registered widgets and for built-widget renderer partials
268
+ alike — both receive `filter_state` and read it to shape their own request.
269
+ - **Register a renderer for every visualization the builder offers.** A built
270
+ widget renders through the partial registered with
271
+ `DashKit.register_renderer`; one with no registered renderer falls back to the
272
+ `missing_renderer` partial. Set `DashKit.available_sources_for` to expose the
273
+ builder UI in the first place.
274
+ - **Persist built widgets through the engine routes**
275
+ (`create_definition` / `update_definition` / `destroy_definition`), not by
276
+ writing `WidgetDefinition` rows by hand.
277
+ - **Scope to the current owner.** Always look up dashboards through
278
+ `DashKit::Dashboard.for_owner(owner)` and set `current_owner_method` so
279
+ DashKit never leaks one owner's dashboard to another. `Dashboard` is
280
+ polymorphic on `owner`.
281
+ - **Persist preferences through the model, not by hand.** Use the
282
+ `WidgetManagement` methods (`toggle_widget`, `move_widget_up`,
283
+ `move_widget_down`, `update_filter`) or the engine routes; do not write
284
+ `widget_order` / `hidden_widgets` / `filter_state` directly.
285
+ - **Let widgets lazy-load.** Render through `dash_kit_render_widgets` /
286
+ `dash_kit_widget_frame` so each widget loads in its own Turbo Frame; do not
287
+ inline widget bodies.
288
+ - **Mobile-first, Tailwind, dark mode.** The shipped UI is styled with Tailwind
289
+ and supports dark mode; host styling should match. The primary surface is
290
+ often a native webview.
291
+
292
+ ### Requirements
293
+
294
+ - Ruby >= 3.2, Rails >= 7.1.
295
+ - `turbo-rails` for Turbo Frames and Stream refreshes.
296
+ - `keystone_ui` (a ViewComponent-based UI gem) for shared UI components.
297
+ - `sortablejs`, pinned via importmap, for drag reordering.
298
+