data_porter 0.1.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/.claude/commands/blog-status.md +10 -0
- data/.claude/commands/blog.md +109 -0
- data/.claude/commands/task-done.md +27 -0
- data/.claude/commands/tm/add-dependency.md +58 -0
- data/.claude/commands/tm/add-subtask.md +79 -0
- data/.claude/commands/tm/add-task.md +81 -0
- data/.claude/commands/tm/analyze-complexity.md +124 -0
- data/.claude/commands/tm/analyze-project.md +100 -0
- data/.claude/commands/tm/auto-implement-tasks.md +100 -0
- data/.claude/commands/tm/command-pipeline.md +80 -0
- data/.claude/commands/tm/complexity-report.md +120 -0
- data/.claude/commands/tm/convert-task-to-subtask.md +74 -0
- data/.claude/commands/tm/expand-all-tasks.md +52 -0
- data/.claude/commands/tm/expand-task.md +52 -0
- data/.claude/commands/tm/fix-dependencies.md +82 -0
- data/.claude/commands/tm/help.md +101 -0
- data/.claude/commands/tm/init-project-quick.md +49 -0
- data/.claude/commands/tm/init-project.md +53 -0
- data/.claude/commands/tm/install-taskmaster.md +118 -0
- data/.claude/commands/tm/learn.md +106 -0
- data/.claude/commands/tm/list-tasks-by-status.md +42 -0
- data/.claude/commands/tm/list-tasks-with-subtasks.md +30 -0
- data/.claude/commands/tm/list-tasks.md +46 -0
- data/.claude/commands/tm/next-task.md +69 -0
- data/.claude/commands/tm/parse-prd-with-research.md +51 -0
- data/.claude/commands/tm/parse-prd.md +52 -0
- data/.claude/commands/tm/project-status.md +67 -0
- data/.claude/commands/tm/quick-install-taskmaster.md +23 -0
- data/.claude/commands/tm/remove-all-subtasks.md +94 -0
- data/.claude/commands/tm/remove-dependency.md +65 -0
- data/.claude/commands/tm/remove-subtask.md +87 -0
- data/.claude/commands/tm/remove-subtasks.md +89 -0
- data/.claude/commands/tm/remove-task.md +110 -0
- data/.claude/commands/tm/setup-models.md +52 -0
- data/.claude/commands/tm/show-task.md +85 -0
- data/.claude/commands/tm/smart-workflow.md +58 -0
- data/.claude/commands/tm/sync-readme.md +120 -0
- data/.claude/commands/tm/tm-main.md +147 -0
- data/.claude/commands/tm/to-cancelled.md +58 -0
- data/.claude/commands/tm/to-deferred.md +50 -0
- data/.claude/commands/tm/to-done.md +47 -0
- data/.claude/commands/tm/to-in-progress.md +39 -0
- data/.claude/commands/tm/to-pending.md +35 -0
- data/.claude/commands/tm/to-review.md +43 -0
- data/.claude/commands/tm/update-single-task.md +122 -0
- data/.claude/commands/tm/update-task.md +75 -0
- data/.claude/commands/tm/update-tasks-from-id.md +111 -0
- data/.claude/commands/tm/validate-dependencies.md +72 -0
- data/.claude/commands/tm/view-models.md +52 -0
- data/.env.example +12 -0
- data/.mcp.json +24 -0
- data/.taskmaster/CLAUDE.md +435 -0
- data/.taskmaster/config.json +44 -0
- data/.taskmaster/docs/prd.txt +2044 -0
- data/.taskmaster/state.json +6 -0
- data/.taskmaster/tasks/task_001.md +19 -0
- data/.taskmaster/tasks/task_002.md +19 -0
- data/.taskmaster/tasks/task_003.md +19 -0
- data/.taskmaster/tasks/task_004.md +19 -0
- data/.taskmaster/tasks/task_005.md +19 -0
- data/.taskmaster/tasks/task_006.md +19 -0
- data/.taskmaster/tasks/task_007.md +19 -0
- data/.taskmaster/tasks/task_008.md +19 -0
- data/.taskmaster/tasks/task_009.md +19 -0
- data/.taskmaster/tasks/task_010.md +19 -0
- data/.taskmaster/tasks/task_011.md +19 -0
- data/.taskmaster/tasks/task_012.md +19 -0
- data/.taskmaster/tasks/task_013.md +19 -0
- data/.taskmaster/tasks/task_014.md +19 -0
- data/.taskmaster/tasks/task_015.md +19 -0
- data/.taskmaster/tasks/task_016.md +19 -0
- data/.taskmaster/tasks/task_017.md +19 -0
- data/.taskmaster/tasks/task_018.md +19 -0
- data/.taskmaster/tasks/task_019.md +19 -0
- data/.taskmaster/tasks/task_020.md +19 -0
- data/.taskmaster/tasks/tasks.json +299 -0
- data/.taskmaster/templates/example_prd.txt +47 -0
- data/.taskmaster/templates/example_prd_rpg.txt +511 -0
- data/CHANGELOG.md +29 -0
- data/CLAUDE.md +65 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/CONTRIBUTING.md +49 -0
- data/LICENSE +21 -0
- data/README.md +463 -0
- data/Rakefile +12 -0
- data/app/assets/stylesheets/data_porter/application.css +646 -0
- data/app/channels/data_porter/import_channel.rb +10 -0
- data/app/controllers/data_porter/imports_controller.rb +68 -0
- data/app/javascript/data_porter/progress_controller.js +33 -0
- data/app/jobs/data_porter/dry_run_job.rb +12 -0
- data/app/jobs/data_porter/import_job.rb +12 -0
- data/app/jobs/data_porter/parse_job.rb +12 -0
- data/app/models/data_porter/data_import.rb +49 -0
- data/app/views/data_porter/imports/index.html.erb +142 -0
- data/app/views/data_porter/imports/new.html.erb +88 -0
- data/app/views/data_porter/imports/show.html.erb +49 -0
- data/config/database.yml +3 -0
- data/config/routes.rb +12 -0
- data/docs/SPEC.md +2012 -0
- data/docs/UI.md +32 -0
- data/docs/blog/001-why-build-a-data-import-engine.md +166 -0
- data/docs/blog/002-scaffolding-a-rails-engine.md +188 -0
- data/docs/blog/003-configuration-dsl.md +222 -0
- data/docs/blog/004-store-model-jsonb.md +237 -0
- data/docs/blog/005-target-dsl.md +284 -0
- data/docs/blog/006-parsing-csv-sources.md +300 -0
- data/docs/blog/007-orchestrator.md +247 -0
- data/docs/blog/008-actioncable-stimulus.md +376 -0
- data/docs/blog/009-phlex-ui-components.md +446 -0
- data/docs/blog/010-controllers-routing.md +374 -0
- data/docs/blog/011-generators.md +364 -0
- data/docs/blog/012-json-api-sources.md +323 -0
- data/docs/blog/013-testing-rails-engine.md +618 -0
- data/docs/blog/014-dry-run.md +307 -0
- data/docs/blog/015-publishing-retro.md +264 -0
- data/docs/blog/016-erb-view-templates.md +431 -0
- data/docs/blog/017-showcase-final-retro.md +220 -0
- data/docs/blog/BACKLOG.md +8 -0
- data/docs/blog/SERIES.md +154 -0
- data/docs/screenshots/index-with-previewing.jpg +0 -0
- data/docs/screenshots/index.jpg +0 -0
- data/docs/screenshots/modal-new-import.jpg +0 -0
- data/docs/screenshots/preview.jpg +0 -0
- data/lib/data_porter/broadcaster.rb +29 -0
- data/lib/data_porter/components/base.rb +10 -0
- data/lib/data_porter/components/failure_alert.rb +20 -0
- data/lib/data_porter/components/preview_table.rb +54 -0
- data/lib/data_porter/components/progress_bar.rb +33 -0
- data/lib/data_porter/components/results_summary.rb +19 -0
- data/lib/data_porter/components/status_badge.rb +16 -0
- data/lib/data_porter/components/summary_cards.rb +30 -0
- data/lib/data_porter/components.rb +14 -0
- data/lib/data_porter/configuration.rb +25 -0
- data/lib/data_porter/dsl/api_config.rb +25 -0
- data/lib/data_porter/dsl/column.rb +17 -0
- data/lib/data_porter/engine.rb +15 -0
- data/lib/data_porter/orchestrator.rb +141 -0
- data/lib/data_porter/record_validator.rb +32 -0
- data/lib/data_porter/registry.rb +33 -0
- data/lib/data_porter/sources/api.rb +49 -0
- data/lib/data_porter/sources/base.rb +35 -0
- data/lib/data_porter/sources/csv.rb +43 -0
- data/lib/data_porter/sources/json.rb +45 -0
- data/lib/data_porter/sources.rb +20 -0
- data/lib/data_porter/store_models/error.rb +13 -0
- data/lib/data_porter/store_models/import_record.rb +52 -0
- data/lib/data_porter/store_models/report.rb +21 -0
- data/lib/data_porter/target.rb +89 -0
- data/lib/data_porter/type_validator.rb +46 -0
- data/lib/data_porter/version.rb +5 -0
- data/lib/data_porter.rb +32 -0
- data/lib/generators/data_porter/install/install_generator.rb +33 -0
- data/lib/generators/data_porter/install/templates/create_data_porter_imports.rb.erb +21 -0
- data/lib/generators/data_porter/install/templates/initializer.rb +30 -0
- data/lib/generators/data_porter/target/target_generator.rb +44 -0
- data/lib/generators/data_porter/target/templates/target.rb.tt +20 -0
- data/sig/data_porter.rbs +4 -0
- metadata +274 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Building DataPorter #9 -- Building the UI with Phlex & Tailwind"
|
|
3
|
+
series: "Building DataPorter - A Data Import Engine for Rails"
|
|
4
|
+
part: 9
|
|
5
|
+
tags: [ruby, rails, rails-engine, gem-development, phlex, components, tailwind, ui]
|
|
6
|
+
published: false
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Building the UI with Phlex & Tailwind
|
|
10
|
+
|
|
11
|
+
> How to build a full component library for a Rails engine using Phlex, scope all CSS with a `dp-` prefix so styles never leak into the host app, and generate preview tables dynamically from the Target DSL.
|
|
12
|
+
|
|
13
|
+
## Context
|
|
14
|
+
|
|
15
|
+
This is part 9 of the series where we build **DataPorter**, a mountable Rails engine for data import workflows. In [part 8](#), we wired up ActionCable and Stimulus so users get real-time progress updates as imports run in the background.
|
|
16
|
+
|
|
17
|
+
We now have a working engine: targets define imports, sources parse files, the Orchestrator coordinates the workflow, and ActionCable pushes updates to the browser. But the browser has nothing to render. Every status, every preview row, every progress bar needs a component. In this article, we build the entire view layer -- seven Phlex components that turn DataPorter's internal data structures into HTML the user can actually see.
|
|
18
|
+
|
|
19
|
+
## Why Phlex (not ERB or ViewComponent)
|
|
20
|
+
|
|
21
|
+
A Rails engine's view layer needs to ship inside the gem. That immediately rules out the typical ERB-in-`app/views` workflow, because template files in engines are fragile -- they depend on the host app's layout, helpers, and asset pipeline. We need components that are self-contained Ruby objects: no template files to resolve, no helper dependencies, no partial lookup paths.
|
|
22
|
+
|
|
23
|
+
ViewComponent solves some of this, but it still relies on sidecar templates or inline `erb` calls by default. Phlex takes a different approach: the template *is* the Ruby class. A component is a plain object with a `view_template` method that uses a Ruby DSL to emit HTML. No template resolution. No string interpolation. No implicit dependencies. The component is just a class you instantiate and call.
|
|
24
|
+
|
|
25
|
+
For a gem, this is ideal. Every component lives in `lib/`, ships with the gem, and renders anywhere -- in a controller, in a test, in a Turbo Stream. There is no asset pipeline to configure, no `app/components` directory to worry about, and no risk of template name collisions with the host app.
|
|
26
|
+
|
|
27
|
+
The other constraint is CSS isolation. DataPorter's components need styles, but those styles must not leak into the host app's design system. We solve this with a `dp-` prefix convention on every CSS class, which we will cover in detail below.
|
|
28
|
+
|
|
29
|
+
## Implementation
|
|
30
|
+
|
|
31
|
+
### Step 1 -- The Base component
|
|
32
|
+
|
|
33
|
+
Every DataPorter component inherits from a shared base class that extends `Phlex::HTML`:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
# lib/data_porter/components/base.rb
|
|
37
|
+
require "phlex"
|
|
38
|
+
|
|
39
|
+
module DataPorter
|
|
40
|
+
module Components
|
|
41
|
+
class Base < Phlex::HTML
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This is intentionally minimal. The base class exists as a single point of inheritance so we can add shared behavior later -- a common CSS helper, a default wrapper, an HTML safety policy -- without touching every component. Right now it does nothing but establish the hierarchy. That is fine. The value is in the seam, not the code.
|
|
48
|
+
|
|
49
|
+
The module barrel file requires all components in order:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
# lib/data_porter/components.rb
|
|
53
|
+
require_relative "components/base"
|
|
54
|
+
require_relative "components/status_badge"
|
|
55
|
+
require_relative "components/summary_cards"
|
|
56
|
+
require_relative "components/preview_table"
|
|
57
|
+
require_relative "components/progress_bar"
|
|
58
|
+
require_relative "components/results_summary"
|
|
59
|
+
require_relative "components/failure_alert"
|
|
60
|
+
|
|
61
|
+
module DataPorter
|
|
62
|
+
module Components
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Step 2 -- StatusBadge: the simplest component
|
|
68
|
+
|
|
69
|
+
The StatusBadge renders a single `<span>` with the import's current status. It demonstrates the pattern every component follows:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
# lib/data_porter/components/status_badge.rb
|
|
73
|
+
module DataPorter
|
|
74
|
+
module Components
|
|
75
|
+
class StatusBadge < Base
|
|
76
|
+
def initialize(status:)
|
|
77
|
+
super()
|
|
78
|
+
@status = status.to_s
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def view_template
|
|
82
|
+
span(class: "dp-badge dp-badge--#{@status}") { @status.capitalize }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The constructor takes a keyword argument and calls `super()` -- Phlex requires this. The `view_template` method emits a span with two CSS classes: a base class `dp-badge` for shared badge styles, and a modifier class `dp-badge--#{@status}` for status-specific colors. The naming follows BEM conventions, but with the `dp-` prefix to scope everything to DataPorter.
|
|
90
|
+
|
|
91
|
+
Rendering it is a method call:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
DataPorter::Components::StatusBadge.new(status: "completed").call
|
|
95
|
+
# => '<span class="dp-badge dp-badge--completed">Completed</span>'
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
No partial lookup, no view context, no render helpers. Just instantiate and call.
|
|
99
|
+
|
|
100
|
+
### Step 3 -- SummaryCards: parsing report data into a dashboard
|
|
101
|
+
|
|
102
|
+
After the Orchestrator's `parse!` phase completes, the import has a Report object with counts for each record status. The SummaryCards component turns that into a row of four cards:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
# lib/data_porter/components/summary_cards.rb
|
|
106
|
+
module DataPorter
|
|
107
|
+
module Components
|
|
108
|
+
class SummaryCards < Base
|
|
109
|
+
def initialize(report:)
|
|
110
|
+
super()
|
|
111
|
+
@report = report
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def view_template
|
|
115
|
+
div(class: "dp-summary-cards") do
|
|
116
|
+
card("dp-card--complete", @report.complete_count, "Ready")
|
|
117
|
+
card("dp-card--partial", @report.partial_count, "Incomplete")
|
|
118
|
+
card("dp-card--missing", @report.missing_count, "Missing")
|
|
119
|
+
card("dp-card--duplicate", @report.duplicate_count, "Duplicates")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def card(css_class, count, label)
|
|
126
|
+
div(class: "dp-card #{css_class}") do
|
|
127
|
+
strong { count.to_s }
|
|
128
|
+
plain " #{label}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The private `card` method extracts the repeated pattern: a `div` with a modifier class, a bold count, and a label. Each card gets a status-specific class (`dp-card--complete`, `dp-card--partial`, etc.) so the host app's stylesheet -- or DataPorter's own default styles -- can color-code them. The `plain` helper emits raw text without wrapping it in an element, keeping the markup minimal.
|
|
137
|
+
|
|
138
|
+
This component takes a single `report:` argument and does not care where the report came from. In production it comes from `data_import.report`. In a test, you can pass a plain `Report.new(complete_count: 10, ...)`. The component has no database dependency.
|
|
139
|
+
|
|
140
|
+
### Step 4 -- PreviewTable: dynamic columns from the Target DSL
|
|
141
|
+
|
|
142
|
+
The PreviewTable is the most complex component and the one that justifies choosing Phlex. It builds an HTML table whose columns are defined dynamically by the Target class's column DSL -- the same DSL we built in part 5. A target with two columns produces a table with two data columns. A target with ten produces ten. No template changes needed.
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
# lib/data_porter/components/preview_table.rb
|
|
146
|
+
module DataPorter
|
|
147
|
+
module Components
|
|
148
|
+
class PreviewTable < Base
|
|
149
|
+
def initialize(columns:, records:)
|
|
150
|
+
super()
|
|
151
|
+
@columns = columns
|
|
152
|
+
@records = records
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def view_template
|
|
156
|
+
div(class: "dp-preview-table") do
|
|
157
|
+
table(class: "dp-table") do
|
|
158
|
+
render_header
|
|
159
|
+
render_body
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def render_header
|
|
167
|
+
thead do
|
|
168
|
+
tr do
|
|
169
|
+
th { "#" }
|
|
170
|
+
th { "Status" }
|
|
171
|
+
@columns.each { |col| th { col.label } }
|
|
172
|
+
th { "Errors" }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def render_body
|
|
178
|
+
tbody do
|
|
179
|
+
@records.each { |record| render_row(record) }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def render_row(record)
|
|
184
|
+
tr(class: "dp-row--#{record.status}") do
|
|
185
|
+
td { record.line_number.to_s }
|
|
186
|
+
td { record.status }
|
|
187
|
+
@columns.each { |col| td { record.data[col.name.to_s].to_s } }
|
|
188
|
+
td(class: "dp-errors") { error_messages(record) }
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def error_messages(record)
|
|
193
|
+
record.errors_list.map(&:message).join(", ")
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The header row always starts with a line number column and a status column, then iterates over the target's `_columns` to produce one `<th>` per declared column using `col.label` (the human-readable name), and ends with an errors column. The body does the same iteration for each record, pulling values from `record.data` by column name.
|
|
201
|
+
|
|
202
|
+
Each row gets a `dp-row--#{record.status}` class. A row with status `complete` gets `dp-row--complete`, a row with `missing` gets `dp-row--missing`. This lets the stylesheet highlight problem rows -- red background for missing data, yellow for partial, green for complete -- without any conditional logic in the component.
|
|
203
|
+
|
|
204
|
+
The key design choice here is that the PreviewTable takes `columns:` and `records:` as separate arguments rather than a DataImport object. This keeps it decoupled: the component does not need to know how to resolve a target or load records from the database. The controller (or whoever renders it) passes the data in, and the component turns it into HTML.
|
|
205
|
+
|
|
206
|
+
### Step 5 -- ProgressBar: bridging Phlex and Stimulus
|
|
207
|
+
|
|
208
|
+
The ProgressBar component is where server-rendered Phlex meets client-side Stimulus. It renders the initial HTML with Stimulus data attributes, and the Stimulus controller (from part 8) takes over to animate the bar as ActionCable pushes updates:
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
# lib/data_porter/components/progress_bar.rb
|
|
212
|
+
module DataPorter
|
|
213
|
+
module Components
|
|
214
|
+
class ProgressBar < Base
|
|
215
|
+
def initialize(import_id:)
|
|
216
|
+
super()
|
|
217
|
+
@import_id = import_id
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def view_template
|
|
221
|
+
div(class: "dp-progress", **stimulus_controller_attrs) do
|
|
222
|
+
render_bar
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def render_bar
|
|
229
|
+
div(class: "dp-progress-bar",
|
|
230
|
+
data_data_porter__progress_target: "bar",
|
|
231
|
+
style: "width: 0%") do
|
|
232
|
+
span(data_data_porter__progress_target: "text") { "0%" }
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def stimulus_controller_attrs
|
|
237
|
+
{
|
|
238
|
+
data_controller: "data-porter--progress",
|
|
239
|
+
data_data_porter__progress_id_value: @import_id.to_s
|
|
240
|
+
}
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The Stimulus naming convention for engines uses the `data-porter--progress` format -- double dash separating the namespace from the controller name. Phlex's HTML DSL converts Ruby keyword arguments with underscores to hyphenated HTML attributes, so `data_data_porter__progress_target` becomes `data-data-porter--progress-target` in the output. The double underscore in Ruby maps to the double dash that Stimulus expects for namespaced controllers.
|
|
248
|
+
|
|
249
|
+
The component renders the bar at `width: 0%`. The Stimulus controller subscribes to the ActionCable channel using the `id_value` and updates the width and text as progress events arrive. The Phlex component's only job is to emit the correct initial HTML with the right data attributes. It does not know about ActionCable or JavaScript.
|
|
250
|
+
|
|
251
|
+
### Step 6 -- ResultsSummary and FailureAlert: post-import components
|
|
252
|
+
|
|
253
|
+
After the import completes, two components display the outcome. ResultsSummary shows the final counts:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
# lib/data_porter/components/results_summary.rb
|
|
257
|
+
module DataPorter
|
|
258
|
+
module Components
|
|
259
|
+
class ResultsSummary < Base
|
|
260
|
+
def initialize(report:)
|
|
261
|
+
super()
|
|
262
|
+
@report = report
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def view_template
|
|
266
|
+
div(class: "dp-results") do
|
|
267
|
+
p { "Created: #{@report.imported_count}" }
|
|
268
|
+
p { "Errors: #{@report.errored_count}" }
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
FailureAlert handles the catastrophic failure case -- when the Orchestrator catches an exception and transitions to `failed`:
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
# lib/data_porter/components/failure_alert.rb
|
|
280
|
+
module DataPorter
|
|
281
|
+
module Components
|
|
282
|
+
class FailureAlert < Base
|
|
283
|
+
def initialize(report:)
|
|
284
|
+
super()
|
|
285
|
+
@report = report
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def view_template
|
|
289
|
+
div(class: "dp-alert dp-alert--danger") do
|
|
290
|
+
@report.error_reports.each do |err|
|
|
291
|
+
p { err.message }
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Both are intentionally simple. The ResultsSummary renders two paragraphs. The FailureAlert iterates over error reports and renders each message. There is no conditional logic, no branching, no fallback states. The controller decides which component to render based on the import's status. The components just render what they are given.
|
|
301
|
+
|
|
302
|
+
## The `dp-` CSS prefix strategy
|
|
303
|
+
|
|
304
|
+
Every CSS class in the component library starts with `dp-`: `dp-badge`, `dp-table`, `dp-progress`, `dp-alert`, `dp-card`, `dp-row`, `dp-errors`, `dp-results`, `dp-summary-cards`, `dp-preview-table`. This is a manual namespacing strategy that solves a real problem: a Rails engine's styles must coexist with the host app's styles without collisions.
|
|
305
|
+
|
|
306
|
+
If DataPorter used generic class names like `badge`, `table`, or `alert`, they would conflict with Bootstrap, Tailwind UI, or whatever the host app uses. If we used CSS modules or Shadow DOM, we would add complexity and browser constraints that a server-rendered Rails app should not need.
|
|
307
|
+
|
|
308
|
+
The `dp-` prefix is the simplest approach that works. It is a convention, not a mechanism -- there is nothing stopping someone from using `dp-badge` in their own app. But it is unlikely, and it is obvious when it happens. The naming follows BEM-style modifiers (`dp-badge--failed`, `dp-row--complete`, `dp-card--partial`) so the structure is predictable.
|
|
309
|
+
|
|
310
|
+
DataPorter ships with a default stylesheet that targets these classes. Host apps can override any of them. Because every class is prefixed, a blanket override like `.dp-badge { ... }` will only affect DataPorter's badges without touching anything else on the page.
|
|
311
|
+
|
|
312
|
+
This strategy also plays well with Tailwind. If the host app uses Tailwind, they can style DataPorter's components with `@apply` inside a `dp-`-prefixed selector, or they can use Tailwind's `@layer` to scope overrides. The prefix gives them a clean hook without requiring any configuration in DataPorter itself.
|
|
313
|
+
|
|
314
|
+
## Decisions & tradeoffs
|
|
315
|
+
|
|
316
|
+
| Decision | We chose | Over | Because |
|
|
317
|
+
|----------|----------|------|---------|
|
|
318
|
+
| Component framework | Phlex | ERB partials, ViewComponent | Components are plain Ruby objects in `lib/`; no template resolution, no sidecar files, no asset pipeline dependency |
|
|
319
|
+
| CSS scoping | Manual `dp-` prefix on all classes | CSS modules, Shadow DOM, Tailwind prefix config | Simplest approach that prevents collisions; no build step, no browser constraints, works everywhere |
|
|
320
|
+
| Naming convention | BEM-style with `dp-` namespace (`dp-badge--failed`) | Flat class names, utility classes | Predictable structure; modifiers make status-specific styling obvious |
|
|
321
|
+
| PreviewTable inputs | Separate `columns:` and `records:` arguments | A single `data_import:` argument | Decouples the component from the database; testable with plain objects |
|
|
322
|
+
| ProgressBar strategy | Server-render initial HTML, let Stimulus animate | Render progress entirely client-side | The initial state (0%) is valid HTML; progressive enhancement means the page works even if JS fails to load |
|
|
323
|
+
| Component granularity | Seven small components | One large "import view" component | Each component is independently testable, replaceable, and composable |
|
|
324
|
+
| Base class | Empty `Base < Phlex::HTML` | Components inherit directly from `Phlex::HTML` | Provides a seam for future shared behavior without touching every component |
|
|
325
|
+
|
|
326
|
+
## Testing it
|
|
327
|
+
|
|
328
|
+
Phlex components are pure Ruby objects, which means testing them does not require a view context, a request, or a Rails controller. You instantiate the component and call `call` to get the HTML string:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
# spec/data_porter/components/status_badge_spec.rb
|
|
332
|
+
RSpec.describe DataPorter::Components::StatusBadge do
|
|
333
|
+
def render(component)
|
|
334
|
+
component.call
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
it "renders a span with status text" do
|
|
338
|
+
html = render(described_class.new(status: "completed"))
|
|
339
|
+
expect(html).to include("Completed")
|
|
340
|
+
expect(html).to include("dp-badge")
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
it "includes status-specific CSS class" do
|
|
344
|
+
html = render(described_class.new(status: "failed"))
|
|
345
|
+
expect(html).to include("dp-badge--failed")
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
it "renders all valid statuses" do
|
|
349
|
+
%w[pending parsing previewing importing completed failed].each do |status|
|
|
350
|
+
html = render(described_class.new(status: status))
|
|
351
|
+
expect(html).to include(status.capitalize)
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
The pattern is the same for every component: define a local `render` helper that calls `component.call`, construct the component with the right arguments, and assert against the HTML string. No Capybara, no request specs, no browser.
|
|
358
|
+
|
|
359
|
+
The PreviewTable spec is the most interesting because it uses a real anonymous Target class to generate columns:
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
# spec/data_porter/components/preview_table_spec.rb
|
|
363
|
+
RSpec.describe DataPorter::Components::PreviewTable do
|
|
364
|
+
let(:target_class) do
|
|
365
|
+
Class.new(DataPorter::Target) do
|
|
366
|
+
label "Guests"
|
|
367
|
+
model_name "Guest"
|
|
368
|
+
|
|
369
|
+
columns do
|
|
370
|
+
column :first_name, type: :string, required: true
|
|
371
|
+
column :last_name, type: :string
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
let(:record) do
|
|
377
|
+
DataPorter::StoreModels::ImportRecord.new(
|
|
378
|
+
line_number: 1,
|
|
379
|
+
status: "complete",
|
|
380
|
+
data: { "first_name" => "Alice", "last_name" => "Smith" }
|
|
381
|
+
)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
it "renders column headers from target" do
|
|
385
|
+
html = render(described_class.new(columns: target_class._columns, records: [record]))
|
|
386
|
+
|
|
387
|
+
expect(html).to include("First name")
|
|
388
|
+
expect(html).to include("Last name")
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
it "renders record data in rows" do
|
|
392
|
+
html = render(described_class.new(columns: target_class._columns, records: [record]))
|
|
393
|
+
|
|
394
|
+
expect(html).to include("Alice")
|
|
395
|
+
expect(html).to include("Smith")
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
it "includes status-specific row class" do
|
|
399
|
+
html = render(described_class.new(columns: target_class._columns, records: [record]))
|
|
400
|
+
expect(html).to include("dp-row--complete")
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
This test proves the full loop: the Target DSL declares columns, the PreviewTable reads those column definitions, and the rendered HTML contains the right headers and data. If someone adds a column to the target, the table grows automatically. No template change required.
|
|
406
|
+
|
|
407
|
+
The ProgressBar spec verifies the Stimulus wiring without needing JavaScript:
|
|
408
|
+
|
|
409
|
+
```ruby
|
|
410
|
+
# spec/data_porter/components/progress_bar_spec.rb
|
|
411
|
+
RSpec.describe DataPorter::Components::ProgressBar do
|
|
412
|
+
it "renders a progress bar with Stimulus data attributes" do
|
|
413
|
+
html = render(described_class.new(import_id: 42))
|
|
414
|
+
|
|
415
|
+
expect(html).to include("dp-progress")
|
|
416
|
+
expect(html).to include("data-controller")
|
|
417
|
+
expect(html).to include("data-porter--progress")
|
|
418
|
+
expect(html).to include("42")
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
it "renders bar and text targets" do
|
|
422
|
+
html = render(described_class.new(import_id: 1))
|
|
423
|
+
|
|
424
|
+
expect(html).to include('data-data-porter--progress-target="bar"')
|
|
425
|
+
expect(html).to include('data-data-porter--progress-target="text"')
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
We do not test that the bar animates. We test that the HTML contract between Phlex and Stimulus is correct -- the right controller name, the right target names, the right value attribute. If those are present, the Stimulus controller (tested separately) will work.
|
|
431
|
+
|
|
432
|
+
## Recap
|
|
433
|
+
|
|
434
|
+
- **Phlex** lets us ship components as plain Ruby classes in `lib/`, with no template files, no view context, and no asset pipeline dependency -- ideal for a Rails engine gem.
|
|
435
|
+
- The **`dp-` prefix** on every CSS class prevents style collisions with the host app, using the simplest possible approach: a naming convention.
|
|
436
|
+
- The **PreviewTable** builds its columns dynamically from the Target DSL, so adding a column to a target automatically adds a column to the preview -- no template changes.
|
|
437
|
+
- The **ProgressBar** bridges server-rendered Phlex and client-side Stimulus by emitting the correct data attributes at render time, then letting JavaScript handle animation.
|
|
438
|
+
- Every component is **independently testable** by calling `component.call` and asserting against the returned HTML string -- no browser, no request context, no Capybara.
|
|
439
|
+
|
|
440
|
+
## Next up
|
|
441
|
+
|
|
442
|
+
We have components, but nothing renders them yet. In part 10, we build the **controllers and routing layer** for the engine -- how ImportsController inherits from the host app's parent controller, how engine routes are namespaced, and how Turbo integration ties the components to the import workflow. The tricky part: making authentication flow from the host app into the engine without coupling to any specific auth library.
|
|
443
|
+
|
|
444
|
+
---
|
|
445
|
+
|
|
446
|
+
*This is part 9 of the series "Building DataPorter - A Data Import Engine for Rails". [Previous: Real-time Progress with ActionCable & Stimulus](#) | [Next: Controllers & Routing in a Rails Engine](#)*
|