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.
Files changed (159) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/commands/blog-status.md +10 -0
  3. data/.claude/commands/blog.md +109 -0
  4. data/.claude/commands/task-done.md +27 -0
  5. data/.claude/commands/tm/add-dependency.md +58 -0
  6. data/.claude/commands/tm/add-subtask.md +79 -0
  7. data/.claude/commands/tm/add-task.md +81 -0
  8. data/.claude/commands/tm/analyze-complexity.md +124 -0
  9. data/.claude/commands/tm/analyze-project.md +100 -0
  10. data/.claude/commands/tm/auto-implement-tasks.md +100 -0
  11. data/.claude/commands/tm/command-pipeline.md +80 -0
  12. data/.claude/commands/tm/complexity-report.md +120 -0
  13. data/.claude/commands/tm/convert-task-to-subtask.md +74 -0
  14. data/.claude/commands/tm/expand-all-tasks.md +52 -0
  15. data/.claude/commands/tm/expand-task.md +52 -0
  16. data/.claude/commands/tm/fix-dependencies.md +82 -0
  17. data/.claude/commands/tm/help.md +101 -0
  18. data/.claude/commands/tm/init-project-quick.md +49 -0
  19. data/.claude/commands/tm/init-project.md +53 -0
  20. data/.claude/commands/tm/install-taskmaster.md +118 -0
  21. data/.claude/commands/tm/learn.md +106 -0
  22. data/.claude/commands/tm/list-tasks-by-status.md +42 -0
  23. data/.claude/commands/tm/list-tasks-with-subtasks.md +30 -0
  24. data/.claude/commands/tm/list-tasks.md +46 -0
  25. data/.claude/commands/tm/next-task.md +69 -0
  26. data/.claude/commands/tm/parse-prd-with-research.md +51 -0
  27. data/.claude/commands/tm/parse-prd.md +52 -0
  28. data/.claude/commands/tm/project-status.md +67 -0
  29. data/.claude/commands/tm/quick-install-taskmaster.md +23 -0
  30. data/.claude/commands/tm/remove-all-subtasks.md +94 -0
  31. data/.claude/commands/tm/remove-dependency.md +65 -0
  32. data/.claude/commands/tm/remove-subtask.md +87 -0
  33. data/.claude/commands/tm/remove-subtasks.md +89 -0
  34. data/.claude/commands/tm/remove-task.md +110 -0
  35. data/.claude/commands/tm/setup-models.md +52 -0
  36. data/.claude/commands/tm/show-task.md +85 -0
  37. data/.claude/commands/tm/smart-workflow.md +58 -0
  38. data/.claude/commands/tm/sync-readme.md +120 -0
  39. data/.claude/commands/tm/tm-main.md +147 -0
  40. data/.claude/commands/tm/to-cancelled.md +58 -0
  41. data/.claude/commands/tm/to-deferred.md +50 -0
  42. data/.claude/commands/tm/to-done.md +47 -0
  43. data/.claude/commands/tm/to-in-progress.md +39 -0
  44. data/.claude/commands/tm/to-pending.md +35 -0
  45. data/.claude/commands/tm/to-review.md +43 -0
  46. data/.claude/commands/tm/update-single-task.md +122 -0
  47. data/.claude/commands/tm/update-task.md +75 -0
  48. data/.claude/commands/tm/update-tasks-from-id.md +111 -0
  49. data/.claude/commands/tm/validate-dependencies.md +72 -0
  50. data/.claude/commands/tm/view-models.md +52 -0
  51. data/.env.example +12 -0
  52. data/.mcp.json +24 -0
  53. data/.taskmaster/CLAUDE.md +435 -0
  54. data/.taskmaster/config.json +44 -0
  55. data/.taskmaster/docs/prd.txt +2044 -0
  56. data/.taskmaster/state.json +6 -0
  57. data/.taskmaster/tasks/task_001.md +19 -0
  58. data/.taskmaster/tasks/task_002.md +19 -0
  59. data/.taskmaster/tasks/task_003.md +19 -0
  60. data/.taskmaster/tasks/task_004.md +19 -0
  61. data/.taskmaster/tasks/task_005.md +19 -0
  62. data/.taskmaster/tasks/task_006.md +19 -0
  63. data/.taskmaster/tasks/task_007.md +19 -0
  64. data/.taskmaster/tasks/task_008.md +19 -0
  65. data/.taskmaster/tasks/task_009.md +19 -0
  66. data/.taskmaster/tasks/task_010.md +19 -0
  67. data/.taskmaster/tasks/task_011.md +19 -0
  68. data/.taskmaster/tasks/task_012.md +19 -0
  69. data/.taskmaster/tasks/task_013.md +19 -0
  70. data/.taskmaster/tasks/task_014.md +19 -0
  71. data/.taskmaster/tasks/task_015.md +19 -0
  72. data/.taskmaster/tasks/task_016.md +19 -0
  73. data/.taskmaster/tasks/task_017.md +19 -0
  74. data/.taskmaster/tasks/task_018.md +19 -0
  75. data/.taskmaster/tasks/task_019.md +19 -0
  76. data/.taskmaster/tasks/task_020.md +19 -0
  77. data/.taskmaster/tasks/tasks.json +299 -0
  78. data/.taskmaster/templates/example_prd.txt +47 -0
  79. data/.taskmaster/templates/example_prd_rpg.txt +511 -0
  80. data/CHANGELOG.md +29 -0
  81. data/CLAUDE.md +65 -0
  82. data/CODE_OF_CONDUCT.md +10 -0
  83. data/CONTRIBUTING.md +49 -0
  84. data/LICENSE +21 -0
  85. data/README.md +463 -0
  86. data/Rakefile +12 -0
  87. data/app/assets/stylesheets/data_porter/application.css +646 -0
  88. data/app/channels/data_porter/import_channel.rb +10 -0
  89. data/app/controllers/data_porter/imports_controller.rb +68 -0
  90. data/app/javascript/data_porter/progress_controller.js +33 -0
  91. data/app/jobs/data_porter/dry_run_job.rb +12 -0
  92. data/app/jobs/data_porter/import_job.rb +12 -0
  93. data/app/jobs/data_porter/parse_job.rb +12 -0
  94. data/app/models/data_porter/data_import.rb +49 -0
  95. data/app/views/data_porter/imports/index.html.erb +142 -0
  96. data/app/views/data_porter/imports/new.html.erb +88 -0
  97. data/app/views/data_porter/imports/show.html.erb +49 -0
  98. data/config/database.yml +3 -0
  99. data/config/routes.rb +12 -0
  100. data/docs/SPEC.md +2012 -0
  101. data/docs/UI.md +32 -0
  102. data/docs/blog/001-why-build-a-data-import-engine.md +166 -0
  103. data/docs/blog/002-scaffolding-a-rails-engine.md +188 -0
  104. data/docs/blog/003-configuration-dsl.md +222 -0
  105. data/docs/blog/004-store-model-jsonb.md +237 -0
  106. data/docs/blog/005-target-dsl.md +284 -0
  107. data/docs/blog/006-parsing-csv-sources.md +300 -0
  108. data/docs/blog/007-orchestrator.md +247 -0
  109. data/docs/blog/008-actioncable-stimulus.md +376 -0
  110. data/docs/blog/009-phlex-ui-components.md +446 -0
  111. data/docs/blog/010-controllers-routing.md +374 -0
  112. data/docs/blog/011-generators.md +364 -0
  113. data/docs/blog/012-json-api-sources.md +323 -0
  114. data/docs/blog/013-testing-rails-engine.md +618 -0
  115. data/docs/blog/014-dry-run.md +307 -0
  116. data/docs/blog/015-publishing-retro.md +264 -0
  117. data/docs/blog/016-erb-view-templates.md +431 -0
  118. data/docs/blog/017-showcase-final-retro.md +220 -0
  119. data/docs/blog/BACKLOG.md +8 -0
  120. data/docs/blog/SERIES.md +154 -0
  121. data/docs/screenshots/index-with-previewing.jpg +0 -0
  122. data/docs/screenshots/index.jpg +0 -0
  123. data/docs/screenshots/modal-new-import.jpg +0 -0
  124. data/docs/screenshots/preview.jpg +0 -0
  125. data/lib/data_porter/broadcaster.rb +29 -0
  126. data/lib/data_porter/components/base.rb +10 -0
  127. data/lib/data_porter/components/failure_alert.rb +20 -0
  128. data/lib/data_porter/components/preview_table.rb +54 -0
  129. data/lib/data_porter/components/progress_bar.rb +33 -0
  130. data/lib/data_porter/components/results_summary.rb +19 -0
  131. data/lib/data_porter/components/status_badge.rb +16 -0
  132. data/lib/data_porter/components/summary_cards.rb +30 -0
  133. data/lib/data_porter/components.rb +14 -0
  134. data/lib/data_porter/configuration.rb +25 -0
  135. data/lib/data_porter/dsl/api_config.rb +25 -0
  136. data/lib/data_porter/dsl/column.rb +17 -0
  137. data/lib/data_porter/engine.rb +15 -0
  138. data/lib/data_porter/orchestrator.rb +141 -0
  139. data/lib/data_porter/record_validator.rb +32 -0
  140. data/lib/data_porter/registry.rb +33 -0
  141. data/lib/data_porter/sources/api.rb +49 -0
  142. data/lib/data_porter/sources/base.rb +35 -0
  143. data/lib/data_porter/sources/csv.rb +43 -0
  144. data/lib/data_porter/sources/json.rb +45 -0
  145. data/lib/data_porter/sources.rb +20 -0
  146. data/lib/data_porter/store_models/error.rb +13 -0
  147. data/lib/data_porter/store_models/import_record.rb +52 -0
  148. data/lib/data_porter/store_models/report.rb +21 -0
  149. data/lib/data_porter/target.rb +89 -0
  150. data/lib/data_porter/type_validator.rb +46 -0
  151. data/lib/data_porter/version.rb +5 -0
  152. data/lib/data_porter.rb +32 -0
  153. data/lib/generators/data_porter/install/install_generator.rb +33 -0
  154. data/lib/generators/data_porter/install/templates/create_data_porter_imports.rb.erb +21 -0
  155. data/lib/generators/data_porter/install/templates/initializer.rb +30 -0
  156. data/lib/generators/data_porter/target/target_generator.rb +44 -0
  157. data/lib/generators/data_porter/target/templates/target.rb.tt +20 -0
  158. data/sig/data_porter.rbs +4 -0
  159. metadata +274 -0
@@ -0,0 +1,431 @@
1
+ ---
2
+ title: "Building DataPorter #16 -- ERB View Templates: Composing Phlex Components"
3
+ series: "Building DataPorter - A Data Import Engine for Rails"
4
+ part: 16
5
+ tags: [ruby, rails, rails-engine, gem-development, erb, phlex, views, active-storage, testing]
6
+ published: false
7
+ ---
8
+
9
+ # ERB View Templates: Composing Phlex Components
10
+
11
+ > Phlex components are pure Ruby. ERB templates are what the browser actually sees. The glue between the two is a single method: `.call`.
12
+
13
+ ## Context
14
+
15
+ This is part 16 of the series where we build **DataPorter**, a mountable Rails engine for data import workflows. In [part 9](#), we built seven Phlex components -- StatusBadge, SummaryCards, PreviewTable, ProgressBar, ResultsSummary, FailureAlert. In [part 10](#), we built the controller and routes. But we never connected the two.
16
+
17
+ The engine has a fully wired backend and a complete component library, but visiting `/imports` crashes with `ActionView::MissingTemplate`. The `app/views/data_porter/imports/` directory is empty. The controller sets instance variables, the components know how to render HTML, but there is no view template to orchestrate them.
18
+
19
+ In this article, we create three ERB templates -- index, new, and show -- that compose the existing Phlex components into full pages. We also fix a missing `has_one_attached :file` declaration, add a CSS stylesheet for all `dp-*` classes, and upgrade the test infrastructure to support view rendering in Rails 8.
20
+
21
+ ## The missing attachment
22
+
23
+ Before touching views, there is a bug to fix. The CSV and JSON sources both call `@data_import.file.download`, but the DataImport model has no file attachment declared:
24
+
25
+ ```ruby
26
+ # app/models/data_porter/data_import.rb
27
+ module DataPorter
28
+ class DataImport < ActiveRecord::Base
29
+ self.table_name = "data_porter_imports"
30
+
31
+ has_one_attached :file
32
+
33
+ belongs_to :user, polymorphic: true, optional: true
34
+ ```
35
+
36
+ Two changes here. `has_one_attached :file` makes ActiveStorage available on the model. And `optional: true` on the `belongs_to` -- in Rails 5+, `belongs_to` associations are required by default. An engine should not enforce that constraint; the host app decides whether imports require a user.
37
+
38
+ Adding `has_one_attached` has a cascade effect on the test setup. ActiveStorage needs its tables (blobs, attachments, variant_records), a configured service, and a running Rails application to initialize the engine. Our previous spec_helper -- 15 lines of manual requires and an in-memory SQLite connection -- is not enough anymore.
39
+
40
+ ## Upgrading the test infrastructure
41
+
42
+ The spec_helper needed a fundamental change: bootstrapping a real Rails application instead of manually requiring individual components.
43
+
44
+ ```ruby
45
+ # spec/spec_helper.rb
46
+ ENV["RAILS_ENV"] = "test"
47
+
48
+ require "rails"
49
+ require "active_record"
50
+ require "active_job"
51
+ require "action_controller"
52
+ require "action_cable"
53
+ require "active_storage/engine"
54
+
55
+ module TestApp
56
+ class Application < Rails::Application
57
+ config.load_defaults Rails::VERSION::STRING.to_f
58
+ config.eager_load = false
59
+ config.active_storage.service = :test
60
+ config.active_storage.service_configurations = {
61
+ test: { service: "Disk", root: Dir.tmpdir }
62
+ }
63
+ config.active_job.queue_adapter = :test
64
+ config.secret_key_base = "test_secret"
65
+ config.root = File.expand_path("..", __dir__)
66
+ end
67
+ end
68
+
69
+ require "data_porter"
70
+
71
+ Rails.application.initialize!
72
+
73
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
74
+ ```
75
+
76
+ The key insight: `Rails.application.initialize!` must run *after* the connection is established, and *before* the schema is defined. ActiveStorage's engine hooks into the initialization process to register its models and set up the attachment macros. Without `initialize!`, `has_one_attached` raises `NoMethodError` because `ActiveStorage::Attached::Model` is never included into `ActiveRecord::Base`.
77
+
78
+ The schema definition gains three new tables for ActiveStorage:
79
+
80
+ ```ruby
81
+ ActiveRecord::Schema.define do
82
+ create_table :active_storage_blobs, force: true do |t|
83
+ t.string :key, null: false
84
+ t.string :filename, null: false
85
+ t.string :content_type
86
+ t.text :metadata
87
+ t.string :service_name, null: false
88
+ t.bigint :byte_size, null: false
89
+ t.string :checksum
90
+ t.datetime :created_at, null: false
91
+ t.index [:key], unique: true
92
+ end
93
+
94
+ create_table :active_storage_attachments, force: true do |t|
95
+ t.string :name, null: false
96
+ t.references :record, null: false, polymorphic: true, index: false
97
+ t.references :blob, null: false
98
+ t.datetime :created_at, null: false
99
+ t.index %i[record_type record_id name blob_id],
100
+ name: "index_active_storage_attachments_uniqueness",
101
+ unique: true
102
+ end
103
+
104
+ create_table :active_storage_variant_records, force: true do |t|
105
+ t.belongs_to :blob, null: false, index: false
106
+ t.string :variation_digest, null: false
107
+ t.index %i[blob_id variation_digest],
108
+ name: "index_active_storage_variant_records_uniqueness",
109
+ unique: true
110
+ end
111
+ end
112
+ ```
113
+
114
+ And we need a `User` stub and a `config/database.yml` for Rails to find during initialization:
115
+
116
+ ```yaml
117
+ # config/database.yml
118
+ test:
119
+ adapter: sqlite3
120
+ database: ":memory:"
121
+ ```
122
+
123
+ ```ruby
124
+ # spec/spec_helper.rb (after schema)
125
+ class User < ActiveRecord::Base; end unless defined?(User)
126
+ ```
127
+
128
+ The User model was previously not needed because without a full Rails app, the polymorphic `belongs_to` never tried to resolve the `User` constant. With `initialize!`, Rails activates the `belongs_to` presence validation, and any test that creates a `DataImport` with `user_type: "User"` triggers a `NameError`. The stub class fixes it cleanly.
129
+
130
+ One more piece: engine routes must be mounted in the test app *after* initialization:
131
+
132
+ ```ruby
133
+ Rails.application.routes.draw do
134
+ mount DataPorter::Engine, at: "/data_porter"
135
+ end
136
+ ```
137
+
138
+ This is crucial for the view specs. Without it, the engine's URL helpers (`import_path`, `imports_path`) cannot resolve paths because the mount point is unknown.
139
+
140
+ ## Rendering Phlex in ERB
141
+
142
+ DataPorter uses Phlex without `phlex-rails`. This means no `render` helper for Phlex components in ERB templates. Instead, we call `.call` directly, which returns an HTML string, and wrap it in `raw` to prevent double-escaping:
143
+
144
+ ```erb
145
+ <%= raw DataPorter::Components::StatusBadge.new(status: @import.status).call %>
146
+ ```
147
+
148
+ This is explicit and requires no gems, no monkey-patching, no initializer configuration. The tradeoff is that every component call is slightly verbose. But for a gem that ships views, this is a feature: the host app does not need to install phlex-rails, and there is no risk of version conflicts between the gem's Phlex setup and the host app's.
149
+
150
+ ## The index template
151
+
152
+ The index page lists all imports with their status, target, source type, and creation date:
153
+
154
+ ```erb
155
+ <%# app/views/data_porter/imports/index.html.erb %>
156
+ <div class="data-porter">
157
+ <div class="dp-header">
158
+ <h1 class="dp-title">Imports</h1>
159
+ <%= link_to "New Import", new_import_path, class: "dp-btn dp-btn--primary" %>
160
+ </div>
161
+
162
+ <table class="dp-table">
163
+ <thead>
164
+ <tr>
165
+ <th>ID</th>
166
+ <th>Target</th>
167
+ <th>Source</th>
168
+ <th>Status</th>
169
+ <th>Created</th>
170
+ <th></th>
171
+ </tr>
172
+ </thead>
173
+ <tbody>
174
+ <% @imports.each do |import| %>
175
+ <tr>
176
+ <td><%= import.id %></td>
177
+ <td><%= import.target_key %></td>
178
+ <td><%= import.source_type %></td>
179
+ <td><%= raw DataPorter::Components::StatusBadge.new(status: import.status).call %></td>
180
+ <td><%= import.created_at&.strftime("%Y-%m-%d %H:%M") %></td>
181
+ <td><%= link_to "View", import_path(import), class: "dp-link" %></td>
182
+ </tr>
183
+ <% end %>
184
+ </tbody>
185
+ </table>
186
+ </div>
187
+ ```
188
+
189
+ Each row includes a StatusBadge component rendered inline. The outer `<div class="data-porter">` scopes all styles. The URL helpers (`new_import_path`, `import_path`) are resolved through the engine's isolated namespace -- no `data_porter.` prefix needed inside engine views.
190
+
191
+ ## The new import form
192
+
193
+ The form for creating an import uses `form_with` to build fields from the controller's instance variables:
194
+
195
+ ```erb
196
+ <%# app/views/data_porter/imports/new.html.erb %>
197
+ <div class="data-porter">
198
+ <h1 class="dp-title">New Import</h1>
199
+
200
+ <%= form_with model: @import, url: imports_path, class: "dp-form" do |f| %>
201
+ <div class="dp-field">
202
+ <%= f.label :target_key, "Target", class: "dp-label" %>
203
+ <%= f.select :target_key,
204
+ @targets.map { |t| [t[:label], t[:key]] },
205
+ { prompt: "Select a target..." },
206
+ class: "dp-select" %>
207
+ </div>
208
+
209
+ <div class="dp-field">
210
+ <%= f.label :source_type, "Source Type", class: "dp-label" %>
211
+ <%= f.select :source_type,
212
+ DataPorter.configuration.enabled_sources.map { |s| [s.to_s.upcase, s] },
213
+ { prompt: "Select source type..." },
214
+ class: "dp-select" %>
215
+ </div>
216
+
217
+ <div class="dp-field">
218
+ <%= f.label :file, "File", class: "dp-label" %>
219
+ <%= f.file_field :file, class: "dp-file-input" %>
220
+ </div>
221
+
222
+ <div class="dp-actions">
223
+ <%= f.submit "Start Import", class: "dp-btn dp-btn--primary" %>
224
+ <%= link_to "Cancel", imports_path, class: "dp-btn dp-btn--secondary" %>
225
+ </div>
226
+ <% end %>
227
+ </div>
228
+ ```
229
+
230
+ The target select is populated from `@targets`, which comes from `DataPorter::Registry.available` -- an array of `{ key:, label:, icon: }` hashes built from the registered Target classes. The source type select reads from `DataPorter.configuration.enabled_sources`, defaulting to `[:csv, :json, :api]`.
231
+
232
+ The `url: imports_path` on `form_with` is necessary because the model is a `DataPorter::DataImport`, and Rails would try to generate a `data_porter_data_import_path` without the explicit URL. Inside engine views, explicit URLs for form actions avoid routing surprises.
233
+
234
+ ## The show template: status-driven composition
235
+
236
+ The show page is where all the Phlex components come together. The key design: the template renders different components based on the import's current status:
237
+
238
+ ```erb
239
+ <%# app/views/data_porter/imports/show.html.erb %>
240
+ <div class="data-porter">
241
+ <div class="dp-header">
242
+ <h1 class="dp-title">
243
+ <%= @target._label %> Import #<%= @import.id %>
244
+ </h1>
245
+ <%= raw DataPorter::Components::StatusBadge.new(status: @import.status).call %>
246
+ </div>
247
+
248
+ <% if @import.parsing? || @import.importing? || @import.dry_running? %>
249
+ <%= raw DataPorter::Components::ProgressBar.new(import_id: @import.id).call %>
250
+ <% end %>
251
+
252
+ <% if @import.previewing? %>
253
+ <%= raw DataPorter::Components::SummaryCards.new(report: @import.report).call %>
254
+ <%= raw DataPorter::Components::PreviewTable.new(
255
+ columns: @target._columns,
256
+ records: @records
257
+ ).call %>
258
+
259
+ <div class="dp-actions">
260
+ <%= button_to "Confirm", confirm_import_path(@import),
261
+ method: :post, class: "dp-btn dp-btn--primary" %>
262
+ <% if @target._dry_run_enabled %>
263
+ <%= button_to "Dry Run", dry_run_import_path(@import),
264
+ method: :post, class: "dp-btn dp-btn--secondary" %>
265
+ <% end %>
266
+ <%= button_to "Cancel", cancel_import_path(@import),
267
+ method: :post, class: "dp-btn dp-btn--danger" %>
268
+ </div>
269
+ <% end %>
270
+
271
+ <% if @import.completed? %>
272
+ <%= raw DataPorter::Components::ResultsSummary.new(report: @import.report).call %>
273
+ <% end %>
274
+
275
+ <% if @import.failed? %>
276
+ <%= raw DataPorter::Components::FailureAlert.new(report: @import.report).call %>
277
+ <div class="dp-actions">
278
+ <%= button_to "Retry", parse_import_path(@import),
279
+ method: :post, class: "dp-btn dp-btn--primary" %>
280
+ </div>
281
+ <% end %>
282
+ </div>
283
+ ```
284
+
285
+ Five states, five rendering paths:
286
+
287
+ | Status | Components rendered |
288
+ |--------|-------------------|
289
+ | `parsing`, `importing`, `dry_running` | ProgressBar |
290
+ | `previewing` | SummaryCards + PreviewTable + action buttons |
291
+ | `completed` | ResultsSummary |
292
+ | `failed` | FailureAlert + Retry button |
293
+ | `pending` | Header only (waiting for job to start) |
294
+
295
+ The Dry Run button appears only when the target has enabled it (`@target._dry_run_enabled`). This is the DSL flag from part 14 surfacing in the UI -- targets opt in to dry run, and the view respects that choice without any extra configuration.
296
+
297
+ The action buttons use `button_to` which generates a mini-form with a POST request. This matches the route design from part 10: all member actions (confirm, cancel, dry_run, parse) are POST endpoints. No JavaScript needed for these actions -- they are standard form submissions that Turbo handles automatically.
298
+
299
+ ## The CSS stylesheet
300
+
301
+ DataPorter ships a plain CSS file with styles for all `dp-*` classes. No build step, no Tailwind compilation, no PostCSS pipeline:
302
+
303
+ ```css
304
+ /* app/assets/stylesheets/data_porter/application.css */
305
+ .data-porter {
306
+ font-family: system-ui, -apple-system, sans-serif;
307
+ color: #1a1a2e;
308
+ max-width: 1200px;
309
+ margin: 0 auto;
310
+ padding: 1rem;
311
+ }
312
+
313
+ .dp-badge {
314
+ display: inline-block;
315
+ padding: 0.2rem 0.6rem;
316
+ border-radius: 9999px;
317
+ font-size: 0.75rem;
318
+ font-weight: 600;
319
+ text-transform: uppercase;
320
+ }
321
+
322
+ .dp-badge--pending { background: #e2e8f0; color: #475569; }
323
+ .dp-badge--completed { background: #d1fae5; color: #065f46; }
324
+ .dp-badge--failed { background: #fee2e2; color: #991b1b; }
325
+ /* ... one modifier per status */
326
+ ```
327
+
328
+ Every class is scoped under `.data-porter` or prefixed with `dp-`. The host app's styles cannot accidentally override DataPorter's components, and DataPorter's styles cannot leak out. The full stylesheet covers tables, badges, cards, progress bars, forms, buttons, alerts, and results -- everything the components emit.
329
+
330
+ Host apps can override any of these styles. They can also ignore this stylesheet entirely and provide their own. The `dp-` prefix convention means they always have a clean selector to target.
331
+
332
+ ## Testing views in a Rails 8 engine
333
+
334
+ Testing ERB templates in a Rails engine without a dummy app is not well documented. Rails 8 changed how `ActionView::Base` works -- you need `with_empty_template_cache` to create a usable view class, and the view needs both the engine's URL helpers and the main app's URL helpers to resolve paths correctly.
335
+
336
+ The solution lives in a shared helper:
337
+
338
+ ```ruby
339
+ # spec/support/view_test_helper.rb
340
+ module ViewTestHelper
341
+ VIEW_CLASS = ActionView::Base.with_empty_template_cache
342
+ VIEW_CLASS.include DataPorter::Engine.routes.url_helpers
343
+ VIEW_CLASS.include Rails.application.routes.url_helpers
344
+
345
+ def build_view(assigns = {})
346
+ lookup = ActionView::LookupContext.new(view_paths)
347
+ ctrl = DataPorter::ImportsController.new
348
+ ctrl.request = ActionDispatch::TestRequest.create
349
+ ctrl.default_url_options = { host: "localhost" }
350
+
351
+ view = VIEW_CLASS.new(lookup, assigns, ctrl)
352
+ view.default_url_options = { host: "localhost" }
353
+ view
354
+ end
355
+
356
+ def view_paths
357
+ [File.expand_path("../../app/views", __dir__)]
358
+ end
359
+ end
360
+ ```
361
+
362
+ Three things are critical here:
363
+
364
+ **`ActionView::Base.with_empty_template_cache`** -- In Rails 8, `ActionView::Base.new` raises `NotImplementedError` asking for `compiled_method_container`. The `with_empty_template_cache` class method creates a subclass that has the right template caching setup.
365
+
366
+ **Both route helper modules** -- The engine's URL helpers (`import_path`, `imports_path`) resolve paths within the engine. The main app's URL helpers provide the mount point (`data_porter_path`) that the engine needs to construct full URLs. Without the main app helpers, named routes raise `NoMethodError: undefined method 'data_porter_path'`.
367
+
368
+ **`DataPorter::ImportsController.new` as the controller** -- The view delegates `_routes` resolution to its controller. Using `ActionController::Base.new` fails because it has no `_routes` for the engine namespace. Using the actual `ImportsController` -- which inherits the engine's route set -- makes all URL helpers work correctly.
369
+
370
+ The specs themselves are straightforward:
371
+
372
+ ```ruby
373
+ RSpec.describe "data_porter/imports/show.html.erb" do
374
+ include ViewTestHelper
375
+
376
+ context "when previewing" do
377
+ let(:status) { :previewing }
378
+
379
+ it "renders the summary cards" do
380
+ expect(html).to include("dp-summary-cards")
381
+ end
382
+
383
+ it "renders the preview table" do
384
+ expect(html).to include("dp-preview-table")
385
+ end
386
+
387
+ it "shows confirm button" do
388
+ expect(html).to include("Confirm")
389
+ end
390
+ end
391
+
392
+ context "when failed" do
393
+ it "renders the failure alert" do
394
+ expect(html).to include("dp-alert")
395
+ end
396
+
397
+ it "shows retry button" do
398
+ expect(html).to include("Retry")
399
+ end
400
+ end
401
+ end
402
+ ```
403
+
404
+ Each context sets a different import status and verifies that the correct components and buttons are present. The assertions check for CSS class names (`dp-summary-cards`, `dp-alert`) rather than exact HTML structure, making them resilient to markup changes while still catching the important behavior: "this component is rendered for this status".
405
+
406
+ ## Decisions & tradeoffs
407
+
408
+ | Decision | We chose | Over | Because |
409
+ |----------|----------|------|---------|
410
+ | Phlex integration | `raw component.call` in ERB | phlex-rails gem with `render` helper | No extra dependency; explicit about what is happening; host app does not need phlex-rails installed |
411
+ | Template format | ERB wrappers composing Phlex | Pure Phlex page components | ERB is familiar to every Rails developer; forms and URL helpers work naturally; Phlex handles the complex rendering |
412
+ | Status-driven rendering | `if @import.parsing?` conditionals | Separate templates per status, Turbo Frame swapping | Simple, readable, works without JavaScript; one template to understand |
413
+ | CSS approach | Plain CSS file, no build step | Tailwind build, CSS-in-JS, Sass | Zero dependencies; host app can use any CSS pipeline; works out of the box |
414
+ | View test approach | `ActionView::Base.with_empty_template_cache` + engine controller | Dummy app, request specs, Capybara | Fast, no extra infrastructure; tests the template rendering directly |
415
+ | Form URL | Explicit `url: imports_path` | Let Rails infer from model | Avoids routing surprises with engine-namespaced models |
416
+
417
+ ## Recap
418
+
419
+ - **`has_one_attached :file`** was missing from DataImport -- a bug that would crash CSV and JSON imports at runtime. Adding it required upgrading the test infrastructure to bootstrap a full Rails application with ActiveStorage.
420
+ - **ERB templates** compose Phlex components via `raw component.call` -- no phlex-rails dependency needed. The show template renders different components based on import status, making the page dynamic without JavaScript.
421
+ - **Plain CSS** with `dp-*` prefixed classes ships with the gem and works out of the box. Host apps can override or replace it entirely.
422
+ - **View testing in Rails 8** requires `ActionView::Base.with_empty_template_cache`, both engine and main app URL helpers, and the engine's own controller for proper route resolution. The `ViewTestHelper` encapsulates this setup in a reusable module.
423
+ - **22 view specs** cover all three templates and all status-driven rendering paths, running in under a second with no browser or dummy app.
424
+
425
+ ## Next up
426
+
427
+ With the views in place, DataPorter is feature-complete. In the next and final article, we step back for a **showcase and retrospective** -- screenshots of the full workflow in a real app, a walkthrough from upload to import, and reflections on what the 16-article journey taught us about building Rails engines.
428
+
429
+ ---
430
+
431
+ *This is part 16 of the series "Building DataPorter - A Data Import Engine for Rails". [Previous: Publishing & Retrospective](#) | [Next: Showcase & Final Retrospective](#)*
@@ -0,0 +1,220 @@
1
+ ---
2
+ title: "Building DataPorter #17 -- Showcase & Final Retrospective"
3
+ series: "Building DataPorter - A Data Import Engine for Rails"
4
+ part: 17
5
+ tags: [ruby, rails, rails-engine, gem-development, retrospective, showcase, open-source]
6
+ published: false
7
+ ---
8
+
9
+ # Showcase & Final Retrospective
10
+
11
+ > 17 articles, 22 components, one complete gem. Here is DataPorter in action -- and what this series taught me about building Rails engines.
12
+
13
+ ## Context
14
+
15
+ This is the final article in the series where we build **DataPorter**, a mountable Rails engine for data import workflows. In [part 16](#), we connected the last pieces -- ERB view templates composing Phlex components into full pages, a CSS stylesheet, and the ActiveStorage file attachment.
16
+
17
+ DataPorter is now feature-complete. Every layer works: the DSL defines import targets, the sources parse files, the orchestrator coordinates the workflow, ActionCable pushes progress in real time, Phlex components render the UI, and ERB templates tie it all together. In this article, we walk through the full workflow in a real application, then look back at the entire series.
18
+
19
+ ## DataPorter in action
20
+
21
+ ### Installation
22
+
23
+ A host app gets DataPorter running in three commands:
24
+
25
+ ```bash
26
+ bundle add data_porter
27
+ rails generate data_porter:install
28
+ rails db:migrate
29
+ ```
30
+
31
+ The install generator creates everything: the migration for the `data_porter_imports` table, an initializer with sensible defaults, the route mount, and an empty `app/importers/` directory for target classes.
32
+
33
+ ### Defining a target
34
+
35
+ One file, one import type. Here is a target that imports contacts from a CSV:
36
+
37
+ ```ruby
38
+ # app/importers/contact_target.rb
39
+ class ContactTarget < DataPorter::Target
40
+ label "Importer Contact"
41
+ model_name "Contact"
42
+ icon "fas fa-file-import"
43
+ sources :csv
44
+
45
+ columns do
46
+ column :name, type: :string, required: true
47
+ column :email, type: :string
48
+ column :phone_number, type: :string
49
+ column :address, type: :string
50
+ column :room, type: :string
51
+ end
52
+
53
+ def persist(record, context:)
54
+ Contact.create!(record.attributes)
55
+ end
56
+ end
57
+ ```
58
+
59
+ A few lines of DSL, one method. The target declares its columns, marks which are required, and defines how each record is persisted. Everything else -- parsing, validation, progress tracking, UI rendering -- is handled by the engine.
60
+
61
+ ### The workflow
62
+
63
+ **Step 1: Create a new import**
64
+
65
+ The user visits `/imports`, clicks "New Import", and a modal opens. They select the target, choose the source type, drag and drop a file onto the dropzone, and click "Start Import".
66
+
67
+ ![New Import modal with target dropdown, source type select, and dropzone file upload](../screenshots/modal-new-import.jpg)
68
+
69
+ **Step 2: Parsing and preview**
70
+
71
+ The ParseJob runs in the background. ActionCable pushes progress to the browser via the Stimulus-powered progress bar. When parsing completes, the import transitions to `previewing` and the page shows:
72
+
73
+ - **Summary cards**: 14 ready, 0 incomplete, 1 missing, 0 duplicates
74
+ - **Preview table**: every row with its status, data, and any errors highlighted
75
+
76
+ ![Preview page with summary cards and preview table showing record statuses](../screenshots/preview.jpg)
77
+
78
+ The user sees exactly what will happen before anything touches the database.
79
+
80
+ **Step 3: Dry run (optional)**
81
+
82
+ For targets that enable it, a "Dry Run" button appears. Clicking it runs every record through the actual `persist` method inside a transaction, captures any database-level errors (uniqueness violations, foreign key constraints), then rolls back. The preview table updates with a green check or red cross for each record.
83
+
84
+ **Step 4: Confirm and import**
85
+
86
+ The user clicks "Confirm Import". The ImportJob processes each importable record, calling the target's `persist` method for real this time. Progress updates flow through ActionCable. When it finishes, the results summary shows the final counts.
87
+
88
+ **Step 5: Track all imports**
89
+
90
+ The index page lists every import with its status badge. At a glance, the user sees what completed, what failed, and what is still in preview.
91
+
92
+ ![Index page listing imports with colored status badges](../screenshots/index-with-previewing.jpg)
93
+
94
+ ## The architecture at a glance
95
+
96
+ ```
97
+ Host App DataPorter Engine
98
+ --------- -----------------
99
+ app/importers/ lib/data_porter/
100
+ contact_target.rb target.rb (DSL)
101
+ registry.rb (discovery)
102
+ config/initializers/ configuration.rb (config)
103
+ data_porter.rb
104
+ app/models/
105
+ data_import.rb (state + storage)
106
+
107
+ lib/data_porter/sources/
108
+ csv.rb, json.rb, api.rb
109
+
110
+ lib/data_porter/
111
+ orchestrator.rb (parse/import/dry_run)
112
+ record_validator.rb (type checks)
113
+ broadcaster.rb (ActionCable)
114
+
115
+ app/jobs/
116
+ parse_job.rb, import_job.rb, dry_run_job.rb
117
+
118
+ lib/data_porter/components/
119
+ status_badge.rb, summary_cards.rb,
120
+ preview_table.rb, progress_bar.rb,
121
+ results_summary.rb, failure_alert.rb
122
+
123
+ app/views/data_porter/imports/
124
+ index.html.erb, new.html.erb, show.html.erb
125
+
126
+ app/assets/stylesheets/
127
+ data_porter/application.css
128
+ ```
129
+
130
+ The host app provides a Target file and an initializer. The engine provides everything else. The boundary is clean: business logic lives in the Target, infrastructure lives in the engine.
131
+
132
+ ## What the series built
133
+
134
+ | # | Article | Component |
135
+ |---|---------|-----------|
136
+ | 1 | Why build a data import engine? | Motivation, problem statement |
137
+ | 2 | Scaffolding a Rails Engine gem | Engine, isolate_namespace |
138
+ | 3 | Configuration DSL | `DataPorter.configure`, defaults |
139
+ | 4 | StoreModel & JSONB | ImportRecord, Error, Report |
140
+ | 5 | Target DSL | `label`, `columns`, `persist` |
141
+ | 6 | Parsing CSV sources | Source::CSV, ActiveStorage |
142
+ | 7 | The Orchestrator | parse!, import!, error handling |
143
+ | 8 | ActionCable & Stimulus | Broadcaster, ImportChannel, progress bar |
144
+ | 9 | Phlex UI components | 7 components, dp- prefix |
145
+ | 10 | Controllers & Routing | ImportsController, engine routes |
146
+ | 11 | Generators | Install + Target generators |
147
+ | 12 | JSON & API sources | Source::JSON, Source::API |
148
+ | 13 | Testing a Rails Engine | spec_helper, structural specs |
149
+ | 14 | Dry Run | Transaction rollback, enrichment |
150
+ | 15 | Publishing & Retrospective | Gemspec, versioning, lessons |
151
+ | 16 | ERB View Templates | ERB + Phlex composition, CSS |
152
+ | 17 | Showcase & Final Retro | This article |
153
+
154
+ 17 articles. Each one with real code, tests, and an explained decision. Not a theoretical tutorial -- a working gem, built step by step.
155
+
156
+ ## The numbers
157
+
158
+ ```
159
+ Specs: 221 examples, 0 failures
160
+ Rubocop: 80 files, 0 offenses
161
+ Runtime: < 1 second (full suite)
162
+ ```
163
+
164
+ 221 specs covering every layer: models, store models, sources, orchestrator, jobs, channels, Phlex components, controllers, routes, generators, views. Everything runs on in-memory SQLite, without a dummy app, in under a second.
165
+
166
+ ## Reflecting on the approach
167
+
168
+ ### TDD on a gem: the right trade-off
169
+
170
+ The red-green-refactor cycle was applied strictly on every feature. Writing specs first forces you to define the API before the implementation. It feels slow at first -- you write code that does not even compile. But it shortens the total cycle because API decisions are made once, not three times.
171
+
172
+ The trap: TDD does not replace integration tests in the host app. The gem's specs verify that each component works in isolation. They do not verify that the whole thing works with the real app config, real models, and real middleware. The gem tests its wiring. The host app tests its behavior. Both are necessary.
173
+
174
+ ### Rails 8 and engines: the surprises
175
+
176
+ Rails 8 changed subtle things for engines:
177
+
178
+ **`ActionView::Base`** refuses to be instantiated directly. You must go through `with_empty_template_cache`. This is not documented anywhere in the Rails guides -- you discover it when the test raises `NotImplementedError`.
179
+
180
+ **`belongs_to` required by default** applies even when you do not call `initialize!` in older tests. But as soon as you bootstrap a real Rails app (necessary for ActiveStorage), the validation activates and breaks all tests that create a DataImport with `user_type: "User", user_id: 1` without having a real User in the database. The solution: `optional: true` on the association.
181
+
182
+ **Engine URL helpers** require the engine's controller (not a generic `ActionController::Base`) to resolve routes. The view delegates `_routes` to its controller. If the controller does not have the engine's routes, `import_path` raises a cryptic error about `data_porter_path`.
183
+
184
+ ### Phlex without phlex-rails: it works
185
+
186
+ The choice not to depend on phlex-rails was deliberate. Each component is a pure Ruby object in `lib/`. You render it with `.call`. You test it with `.call`. You integrate it in ERB with `raw component.call`. No magic helpers, no template resolution, no conflicts with the host app's view system.
187
+
188
+ The cost: each call is a bit verbose. `<%= raw DataPorter::Components::StatusBadge.new(status: @import.status).call %>` is longer than `<%= render StatusBadge.new(status: @import.status) %>`. But clarity is worth the trade-off. When reading the template, you know exactly what is happening.
189
+
190
+ ### Plain CSS stylesheet: underrated
191
+
192
+ No Tailwind build, no PostCSS, no Sass. One CSS file with `dp-` prefixed classes and CSS custom properties for theming. It works on any Rails app, whether the host uses Sprockets, Propshaft, or importmap. No configuration, no compatibility to manage.
193
+
194
+ The `dp-` prefix prevents collisions. The host app can override any class via the CSS custom properties (`--dp-primary`, `--dp-danger`, etc.) or ignore the stylesheet entirely and provide its own. The convention is simple and sufficient.
195
+
196
+ ## What is next?
197
+
198
+ DataPorter 0.1.0 covers the complete workflow: upload, parse, preview, dry run, import. Here are ideas for future versions:
199
+
200
+ **Batch imports** -- For files with 100k+ rows, `insert_all` in batches instead of `create!` per record. This requires rethinking the `persist` contract.
201
+
202
+ **Turbo Streams** -- Replace the full page reload after a status change with targeted Turbo Stream updates. The show template could update itself without reloading.
203
+
204
+ **Export** -- The reverse path. If we can parse and validate records, we can also serialize them. The Target already has all the necessary information.
205
+
206
+ **Dashboard** -- An overview page with aggregated stats: imports per day, error rate, average processing time. The data is already in the `data_porter_imports` table.
207
+
208
+ ## Final words
209
+
210
+ DataPorter was born from a simple observation: we rebuild the same import workflow in every Rails app. 17 articles later, it is a published gem with a clean DSL, a complete UI, and 221 tests.
211
+
212
+ The method -- strict TDD, one article per feature, documented decisions -- forces you to build something solid. No shortcuts, no "we will see later". Each component exists because a test requires it, and each test exists because a need was identified.
213
+
214
+ The result: one `bundle add data_porter`, one generator, a Target of 15 lines, and any Rails app has a complete import system with preview, real-time validation, dry run, and live progress.
215
+
216
+ That was the plan. It took 17 articles to get there. And it was worth it.
217
+
218
+ ---
219
+
220
+ *This is part 17 and the final article of the series "Building DataPorter - A Data Import Engine for Rails". [Previous: ERB View Templates](#)*
@@ -0,0 +1,8 @@
1
+ # Blog Article Backlog
2
+
3
+ Articles ready to write (all tasks for the part are done).
4
+
5
+ | Part | Title | Tasks | Ready since |
6
+ |------|-------|-------|-------------|
7
+ | 2 | Scaffolding a Rails Engine gem | #1 | 2026-02-06 |
8
+ | 3 | Configuration DSL: making the gem flexible | #2 | 2026-02-06 |