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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +170 -0
- data/Rakefile +18 -0
- data/app/assets/builds/dash_kit/dashboard_grid.css +1 -0
- data/app/assets/builds/dash_kit/dashboard_grid.js +5 -0
- data/app/assets/javascripts/dash_kit/index.js +2 -0
- data/app/assets/stylesheets/dash_kit/application.css +15 -0
- data/app/controllers/dash_kit/application_controller.rb +6 -0
- data/app/controllers/dash_kit/dashboards_controller.rb +139 -0
- data/app/controllers/dash_kit/widget_definitions_controller.rb +33 -0
- data/app/controllers/dash_kit/widgets_controller.rb +39 -0
- data/app/helpers/dash_kit/application_helper.rb +4 -0
- data/app/helpers/dash_kit/dashboard_helper.rb +130 -0
- data/app/javascript/dash_kit/dashboard_grid.jsx +10 -0
- data/app/models/concerns/dash_kit/widget_management.rb +16 -0
- data/app/models/dash_kit/application_record.rb +5 -0
- data/app/models/dash_kit/dashboard.rb +53 -0
- data/app/models/dash_kit/widget_definition.rb +9 -0
- data/app/views/dash_kit/dashboards/_widgets.html.erb +1 -0
- data/app/views/dash_kit/dashboards/edit.html.erb +21 -0
- data/app/views/dash_kit/dashboards/index.html.erb +34 -0
- data/app/views/dash_kit/dashboards/new.html.erb +28 -0
- data/app/views/dash_kit/widget_definitions/_missing_renderer.html.erb +3 -0
- data/app/views/layouts/dash_kit/application.html.erb +17 -0
- data/config/importmap.rb +1 -0
- data/config/routes.rb +16 -0
- data/lib/dash_kit/configuration_backfill.rb +27 -0
- data/lib/dash_kit/engine.rb +19 -0
- data/lib/dash_kit/reference/guide.md +289 -0
- data/lib/dash_kit/reference.rb +11 -0
- data/lib/dash_kit/renderer_registry.rb +21 -0
- data/lib/dash_kit/the_local/agents/dash_kit-develop.md +298 -0
- data/lib/dash_kit/the_local/agents/dash_kit-info.md +298 -0
- data/lib/dash_kit/the_local/agents/dash_kit-install.md +298 -0
- data/lib/dash_kit/the_local.rb +34 -0
- data/lib/dash_kit/version.rb +3 -0
- data/lib/dash_kit/widget_registry.rb +69 -0
- data/lib/dash_kit.rb +72 -0
- data/lib/generators/dash_kit/install_generator.rb +79 -0
- data/lib/generators/dash_kit/templates/create_dash_kit_dashboards.rb.tt +20 -0
- data/lib/generators/dash_kit/templates/create_dash_kit_widget_definitions.rb.tt +12 -0
- data/lib/generators/dash_kit/templates/dash_kit.rb.tt +16 -0
- data/lib/tasks/dash_kit_tasks.rake +9 -0
- 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,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
|
+
|