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,154 @@
1
+ ---
2
+ series: "Building DataPorter - A Data Import Engine for Rails"
3
+ author: ""
4
+ repo: ""
5
+ status: complete
6
+ ---
7
+
8
+ # Series Plan
9
+
10
+ ## Target audience
11
+ Ruby/Rails developers (intermediate+) interested in gem architecture, Rails engines, and clean DSL design.
12
+
13
+ ## Series structure
14
+ Each article: ~5-8 min read, one clear concept, real code, decisions explained.
15
+
16
+ ---
17
+
18
+ ## Articles
19
+
20
+ ### Part 1 — Why build a data import engine?
21
+ - **Tasks:** None (intro)
22
+ - **Hook:** The repetitive import pattern in Rails apps
23
+ - **Covers:** Problem statement, existing solutions (maintenance_tasks, activeadmin_import), why a new gem, what we'll build (3-step workflow diagram)
24
+ - **Key decisions:** Mountable engine vs. lib, isolate_namespace, target audience
25
+ - **File:** `docs/blog/001-why-build-a-data-import-engine.md`
26
+ - **Status:** draft
27
+
28
+ ### Part 2 — Scaffolding a Rails Engine gem
29
+ - **Tasks:** #1
30
+ - **Hook:** `bundle gem` is just the start
31
+ - **Covers:** Gem structure, Engine setup, isolate_namespace, directory tree, gemspec dependencies
32
+ - **Key decisions:** Why isolate_namespace, directory conventions, dependency choices (store_model, phlex, turbo)
33
+ - **File:** `docs/blog/002-scaffolding-a-rails-engine.md`
34
+ - **Status:** draft
35
+
36
+ ### Part 3 — Configuration DSL: making the gem flexible
37
+ - **Tasks:** #2
38
+ - **Hook:** A good gem adapts to any host app
39
+ - **Covers:** Configuration singleton pattern, DSL with `configure` block, sensible defaults, context_builder lambda
40
+ - **Key decisions:** Why not Rails config, lambda vs block for context, what to make configurable vs. convention
41
+ - **File:** `docs/blog/003-configuration-dsl.md`
42
+ - **Status:** draft
43
+
44
+ ### Part 4 — Modeling import data with StoreModel & JSONB
45
+ - **Tasks:** #3, #4
46
+ - **Hook:** Storing structured data without extra tables
47
+ - **Covers:** StoreModel gem, ImportRecord/Error/Report models, JSONB attributes, TypeValidator
48
+ - **Key decisions:** JSONB vs. separate tables, store_model vs. hand-rolled, validation strategy (column-level vs DB-level)
49
+ - **File:** `docs/blog/004-store-model-jsonb.md`
50
+ - **Status:** draft
51
+
52
+ ### Part 5 — Designing a Target DSL
53
+ - **Tasks:** #5, #6
54
+ - **Hook:** One file = one import type, zero boilerplate
55
+ - **Covers:** Target base class, class-level DSL (label, model, columns, csv_mapping), Registry pattern, auto-discovery
56
+ - **Key decisions:** DSL design (class methods vs. instance), class_attribute vs. class instance vars, hook pattern for extensibility
57
+ - **File:** `docs/blog/005-target-dsl.md`
58
+ - **Status:** draft
59
+
60
+ ### Part 6 — Parsing CSV data with Sources
61
+ - **Tasks:** #7, #8
62
+ - **Hook:** The first end-to-end flow
63
+ - **Covers:** DataImport model, migration, Source base class, CSV source, ActiveStorage integration, column mapping
64
+ - **Key decisions:** Polymorphic user association, enum state machine, auto-mapping vs explicit mapping
65
+ - **File:** `docs/blog/006-parsing-csv-sources.md`
66
+ - **Status:** draft
67
+
68
+ ### Part 7 — The Orchestrator: coordinating the import workflow
69
+ - **Tasks:** #9, #10
70
+ - **Hook:** The brain of the engine
71
+ - **Covers:** Orchestrator class (parse! and import!), state transitions, error handling per-record, ActiveJob integration
72
+ - **Key decisions:** Why an orchestrator (not controller logic), transaction boundaries, error recovery strategy
73
+ - **File:** `docs/blog/007-orchestrator.md`
74
+ - **Status:** draft
75
+
76
+ ### Part 8 — Real-time progress with ActionCable & Stimulus
77
+ - **Tasks:** #11, #14
78
+ - **Hook:** Users hate staring at a spinner with no feedback
79
+ - **Covers:** Broadcaster service, ImportChannel, Stimulus controller, progress bar updates, auto-reload on completion
80
+ - **Key decisions:** ActionCable vs. polling vs. SSE, channel naming strategy, Stimulus vs. Turbo Streams
81
+ - **File:** `docs/blog/008-actioncable-stimulus.md`
82
+ - **Status:** draft
83
+
84
+ ### Part 9 — Building the UI with Phlex & Tailwind
85
+ - **Tasks:** #13
86
+ - **Hook:** Auto-generated preview tables from a DSL
87
+ - **Covers:** Phlex components, scoped Tailwind (dp- prefix), preview table with dynamic columns, status badges, CSS custom properties for theming
88
+ - **Key decisions:** Phlex vs. ERB/ViewComponent, Tailwind prefix strategy, how to not pollute host app styles
89
+ - **File:** `docs/blog/009-phlex-ui-components.md`
90
+ - **Status:** draft
91
+
92
+ ### Part 10 — Controllers & routing in a Rails Engine
93
+ - **Tasks:** #12
94
+ - **Hook:** Engine controllers are tricky — here's the clean way
95
+ - **Covers:** ImportsController, inheriting from host's parent controller, engine routes, strong params, Turbo integration
96
+ - **Key decisions:** Dynamic parent controller inheritance, route namespacing, how auth flows from host to engine
97
+ - **File:** `docs/blog/010-controllers-routing.md`
98
+ - **Status:** draft
99
+
100
+ ### Part 11 — Generators: install & target scaffolding
101
+ - **Tasks:** #15, #16
102
+ - **Hook:** Great gems install in one command
103
+ - **Covers:** Install generator (migration, initializer, routes), Target generator (column parsing, template), Rails::Generators API
104
+ - **Key decisions:** What to generate vs. what to configure, template format (ERB .tt), route injection strategy
105
+ - **File:** `docs/blog/011-generators.md`
106
+ - **Status:** draft
107
+
108
+ ### Part 12 — Adding JSON & API sources
109
+ - **Tasks:** #18, #19
110
+ - **Hook:** CSV is just the beginning
111
+ - **Covers:** JSON source (file + raw text), API source (HTTP client, params injection), source plugin architecture
112
+ - **Key decisions:** Source abstraction design, how to handle auth headers, response_root extraction
113
+ - **File:** `docs/blog/012-json-api-sources.md`
114
+ - **Status:** draft
115
+
116
+ ### Part 13 — Testing a Rails Engine with RSpec
117
+ - **Tasks:** #17
118
+ - **Hook:** Testing an engine is different from testing an app
119
+ - **Covers:** Dummy app setup, spec organization, testing StoreModels, mocking ActiveStorage, testing jobs, controller specs
120
+ - **Key decisions:** Dummy app vs. shared examples, factory vs. fixture, what to unit test vs. integration test
121
+ - **File:** `docs/blog/013-testing-rails-engine.md`
122
+ - **Status:** draft
123
+
124
+ ### Part 14 — Dry Run: validate before you persist
125
+ - **Tasks:** #20
126
+ - **Hook:** Preview catches column errors, dry-run catches DB errors
127
+ - **Covers:** Transaction + rollback pattern, enriching records with DB errors, DryRunJob, UI integration
128
+ - **Key decisions:** Why two validation layers, transaction rollback approach, when to offer dry-run
129
+ - **File:** `docs/blog/014-dry-run.md`
130
+ - **Status:** draft
131
+
132
+ ### Part 15 — Publishing the gem & retrospective
133
+ - **Tasks:** None (wrap-up)
134
+ - **Hook:** From idea to rubygems.org
135
+ - **Covers:** Gemspec final polish, CHANGELOG, versioning, publishing, series recap, what worked, what we'd do differently
136
+ - **Key decisions:** Semantic versioning strategy, open source considerations
137
+ - **File:** `docs/blog/015-publishing-retro.md`
138
+ - **Status:** draft
139
+
140
+ ### Part 16 — ERB View Templates: Composing Phlex Components
141
+ - **Tasks:** None (view layer completion)
142
+ - **Hook:** Phlex components are pure Ruby. ERB is what the browser sees. The glue is `.call`.
143
+ - **Covers:** ERB templates composing Phlex, has_one_attached bugfix, ActiveStorage test setup, view testing in Rails 8, plain CSS stylesheet
144
+ - **Key decisions:** raw component.call vs phlex-rails, plain CSS vs build step, view test infrastructure
145
+ - **File:** `docs/blog/016-erb-view-templates.md`
146
+ - **Status:** draft
147
+
148
+ ### Part 17 — Showcase & Final Retrospective
149
+ - **Tasks:** None (series finale)
150
+ - **Hook:** 17 articles, 22 components, one complete gem. DataPorter in action.
151
+ - **Covers:** Full workflow walkthrough with screenshots, architecture overview, series recap, lessons learned, what's next
152
+ - **Key decisions:** N/A (retrospective)
153
+ - **File:** `docs/blog/017-showcase-final-retro.md`
154
+ - **Status:** draft
Binary file
Binary file
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ class Broadcaster
5
+ def initialize(import_id)
6
+ prefix = DataPorter.configuration.cable_channel_prefix
7
+ @channel = "#{prefix}/imports/#{import_id}"
8
+ end
9
+
10
+ def progress(current, total)
11
+ percentage = ((current.to_f / total) * 100).round
12
+ broadcast(status: :processing, percentage: percentage, current: current, total: total)
13
+ end
14
+
15
+ def success
16
+ broadcast(status: :success)
17
+ end
18
+
19
+ def failure(message)
20
+ broadcast(status: :failure, error: message)
21
+ end
22
+
23
+ private
24
+
25
+ def broadcast(message)
26
+ ActionCable.server.broadcast(@channel, message)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phlex"
4
+
5
+ module DataPorter
6
+ module Components
7
+ class Base < Phlex::HTML
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Components
5
+ class FailureAlert < Base
6
+ def initialize(report:)
7
+ super()
8
+ @report = report
9
+ end
10
+
11
+ def view_template
12
+ div(class: "dp-alert dp-alert--danger") do
13
+ @report.error_reports.each do |err|
14
+ p { err.message }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Components
5
+ class PreviewTable < Base
6
+ def initialize(columns:, records:)
7
+ super()
8
+ @columns = columns
9
+ @records = records
10
+ end
11
+
12
+ def view_template
13
+ div(class: "dp-preview-table") do
14
+ table(class: "dp-table") do
15
+ render_header
16
+ render_body
17
+ end
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def render_header
24
+ thead do
25
+ tr do
26
+ th { "#" }
27
+ th { "Status" }
28
+ @columns.each { |col| th { col.label } }
29
+ th { "Errors" }
30
+ end
31
+ end
32
+ end
33
+
34
+ def render_body
35
+ tbody do
36
+ @records.each { |record| render_row(record) }
37
+ end
38
+ end
39
+
40
+ def render_row(record)
41
+ tr(class: "dp-row--#{record.status}") do
42
+ td { record.line_number.to_s }
43
+ td { record.status }
44
+ @columns.each { |col| td { record.data[col.name.to_s].to_s } }
45
+ td(class: "dp-errors") { error_messages(record) }
46
+ end
47
+ end
48
+
49
+ def error_messages(record)
50
+ record.errors_list.map(&:message).join(", ")
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Components
5
+ class ProgressBar < Base
6
+ def initialize(import_id:)
7
+ super()
8
+ @import_id = import_id
9
+ end
10
+
11
+ def view_template
12
+ div(class: "dp-progress", **stimulus_controller_attrs) do
13
+ render_bar
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def render_bar
20
+ div(class: "dp-progress-bar", data_data_porter__progress_target: "bar", style: "width: 0%") do
21
+ span(data_data_porter__progress_target: "text") { "0%" }
22
+ end
23
+ end
24
+
25
+ def stimulus_controller_attrs
26
+ {
27
+ data_controller: "data-porter--progress",
28
+ data_data_porter__progress_id_value: @import_id.to_s
29
+ }
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Components
5
+ class ResultsSummary < Base
6
+ def initialize(report:)
7
+ super()
8
+ @report = report
9
+ end
10
+
11
+ def view_template
12
+ div(class: "dp-results") do
13
+ p { "Created: #{@report.imported_count}" }
14
+ p { "Errors: #{@report.errored_count}" }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Components
5
+ class StatusBadge < Base
6
+ def initialize(status:)
7
+ super()
8
+ @status = status.to_s
9
+ end
10
+
11
+ def view_template
12
+ span(class: "dp-badge dp-badge--#{@status}") { @status.capitalize }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Components
5
+ class SummaryCards < Base
6
+ def initialize(report:)
7
+ super()
8
+ @report = report
9
+ end
10
+
11
+ def view_template
12
+ div(class: "dp-summary-cards") do
13
+ card("dp-card--complete", @report.complete_count, "Ready")
14
+ card("dp-card--partial", @report.partial_count, "Incomplete")
15
+ card("dp-card--missing", @report.missing_count, "Missing")
16
+ card("dp-card--duplicate", @report.duplicate_count, "Duplicates")
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def card(css_class, count, label)
23
+ div(class: "dp-card #{css_class}") do
24
+ strong { count.to_s }
25
+ plain " #{label}"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "components/base"
4
+ require_relative "components/status_badge"
5
+ require_relative "components/summary_cards"
6
+ require_relative "components/preview_table"
7
+ require_relative "components/progress_bar"
8
+ require_relative "components/results_summary"
9
+ require_relative "components/failure_alert"
10
+
11
+ module DataPorter
12
+ module Components
13
+ end
14
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ class Configuration
5
+ attr_accessor :parent_controller,
6
+ :queue_name,
7
+ :storage_service,
8
+ :cable_channel_prefix,
9
+ :context_builder,
10
+ :preview_limit,
11
+ :enabled_sources,
12
+ :scope
13
+
14
+ def initialize
15
+ @parent_controller = "ApplicationController"
16
+ @queue_name = :imports
17
+ @storage_service = :local
18
+ @cable_channel_prefix = "data_porter"
19
+ @context_builder = nil
20
+ @preview_limit = 500
21
+ @enabled_sources = %i[csv json api]
22
+ @scope = nil
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module DSL
5
+ class ApiConfig
6
+ def endpoint(value = nil)
7
+ return @endpoint if value.nil?
8
+
9
+ @endpoint = value
10
+ end
11
+
12
+ def headers(value = nil)
13
+ return @headers if value.nil?
14
+
15
+ @headers = value
16
+ end
17
+
18
+ def response_root(value = nil)
19
+ return @response_root if value.nil?
20
+
21
+ @response_root = value
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module DSL
5
+ Column = Struct.new(:name, :type, :required, :label, :options, keyword_init: true) do
6
+ def initialize(name:, type: :string, required: false, label: nil, **options)
7
+ super(
8
+ name: name.to_sym,
9
+ type: type.to_sym,
10
+ required: required,
11
+ label: label || name.to_s.humanize,
12
+ options: options
13
+ )
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace DataPorter
6
+
7
+ initializer "data_porter.assets.precompile" do |app|
8
+ app.config.assets.precompile += %w[data_porter/application.css] if app.config.respond_to?(:assets)
9
+ end
10
+
11
+ config.to_prepare do
12
+ Dir[Rails.root.join("app/importers/*_target.rb")].each { |f| require f }
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ class Orchestrator
5
+ def initialize(data_import, content: nil)
6
+ @data_import = data_import
7
+ @target = data_import.target_class.new
8
+ @source_options = { content: content }.compact
9
+ end
10
+
11
+ def parse!
12
+ @data_import.parsing!
13
+ records = build_records
14
+ @data_import.update!(records: records, status: :previewing)
15
+ build_report
16
+ rescue StandardError => e
17
+ handle_failure(e)
18
+ end
19
+
20
+ def import!
21
+ @data_import.importing!
22
+ results = import_records
23
+ update_import_report(results)
24
+ @target.after_import(results, context: build_context)
25
+ rescue StandardError => e
26
+ handle_failure(e)
27
+ end
28
+
29
+ def dry_run!
30
+ @data_import.dry_running!
31
+ run_dry_run_records
32
+ @data_import.update!(status: :previewing)
33
+ build_report
34
+ rescue StandardError => e
35
+ handle_failure(e)
36
+ end
37
+
38
+ private
39
+
40
+ def build_records
41
+ source = @data_import.source_class.new(@data_import, **@source_options)
42
+ raw_rows = source.fetch
43
+ columns = @target.class._columns || []
44
+ validator = RecordValidator.new(columns)
45
+
46
+ raw_rows.each_with_index.map do |row, index|
47
+ build_record(row, index, columns, validator)
48
+ end
49
+ end
50
+
51
+ def build_record(row, index, columns, validator)
52
+ record = StoreModels::ImportRecord.new(
53
+ line_number: index + 1,
54
+ data: extract_data(row, columns)
55
+ )
56
+ record = @target.transform(record)
57
+ @target.validate(record)
58
+ validator.validate(record)
59
+ record.determine_status!
60
+ record
61
+ end
62
+
63
+ def extract_data(row, columns)
64
+ columns.each_with_object({}) do |col, hash|
65
+ hash[col.name] = row[col.name] || row[col.name.to_s]
66
+ end
67
+ end
68
+
69
+ def run_dry_run_records
70
+ records = @data_import.records
71
+ importable = records.select(&:importable?)
72
+ context = build_context
73
+
74
+ importable.each do |record|
75
+ dry_run_record(record, context)
76
+ end
77
+
78
+ @data_import.records_will_change!
79
+ @data_import.update!(records: records)
80
+ end
81
+
82
+ def dry_run_record(record, context)
83
+ @target.persist(record, context: context)
84
+ record.dry_run_passed = true
85
+ rescue StandardError => e
86
+ record.dry_run_passed = false
87
+ record.add_error(e.message)
88
+ end
89
+
90
+ def import_records
91
+ importable = @data_import.importable_records
92
+ context = build_context
93
+ results = { created: 0, errored: 0 }
94
+
95
+ importable.each do |record|
96
+ persist_record(record, context, results)
97
+ end
98
+
99
+ @data_import.update!(status: :completed)
100
+ results
101
+ end
102
+
103
+ def persist_record(record, context, results)
104
+ @target.persist(record, context: context)
105
+ results[:created] += 1
106
+ rescue StandardError => e
107
+ record.add_error(e.message)
108
+ @target.on_error(record, e, context: context)
109
+ results[:errored] += 1
110
+ end
111
+
112
+ def update_import_report(results)
113
+ report = @data_import.report || StoreModels::Report.new
114
+ report.imported_count = results[:created]
115
+ report.errored_count = results[:errored]
116
+ @data_import.update!(report: report)
117
+ end
118
+
119
+ def build_report
120
+ summary = @data_import.records_summary
121
+ report = StoreModels::Report.new(
122
+ records_count: @data_import.records.size,
123
+ complete_count: summary["complete"] || 0,
124
+ partial_count: summary["partial"] || 0,
125
+ missing_count: summary["missing"] || 0
126
+ )
127
+ @data_import.update!(report: report)
128
+ end
129
+
130
+ def build_context
131
+ DataPorter.configuration.context_builder&.call(@data_import)
132
+ end
133
+
134
+ def handle_failure(error)
135
+ report = StoreModels::Report.new(
136
+ error_reports: [StoreModels::Error.new(message: error.message)]
137
+ )
138
+ @data_import.update!(status: :failed, report: report)
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ class RecordValidator
5
+ def initialize(columns)
6
+ @columns = columns
7
+ end
8
+
9
+ def validate(record)
10
+ @columns.each do |col|
11
+ value = record.data[col.name]
12
+ validate_required(record, col, value)
13
+ validate_type(record, col, value)
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def validate_required(record, col, value)
20
+ return unless col.required && value.to_s.strip.empty?
21
+
22
+ record.add_error("#{col.label} is required")
23
+ end
24
+
25
+ def validate_type(record, col, value)
26
+ return if value.to_s.strip.empty?
27
+ return if TypeValidator.valid?(value, col.type, col.options)
28
+
29
+ record.add_error("#{col.label}: invalid #{col.type}")
30
+ end
31
+ end
32
+ end