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,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](#)*