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,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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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 |
|