data_porter 0.1.0 → 0.4.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 (168) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/README.md +63 -386
  4. data/ROADMAP.md +89 -0
  5. data/app/assets/javascripts/data_porter/stimulus.min.js +2 -0
  6. data/app/assets/javascripts/data_porter/turbo.min.js +29 -0
  7. data/app/assets/stylesheets/data_porter/alerts.css +25 -0
  8. data/app/assets/stylesheets/data_porter/application.css +12 -646
  9. data/app/assets/stylesheets/data_porter/badges.css +73 -0
  10. data/app/assets/stylesheets/data_porter/base.css +56 -0
  11. data/app/assets/stylesheets/data_porter/cards.css +60 -0
  12. data/app/assets/stylesheets/data_porter/layout.css +128 -0
  13. data/app/assets/stylesheets/data_porter/mapping.css +79 -0
  14. data/app/assets/stylesheets/data_porter/modal.css +49 -0
  15. data/app/assets/stylesheets/data_porter/preview.css +24 -0
  16. data/app/assets/stylesheets/data_porter/progress.css +37 -0
  17. data/app/assets/stylesheets/data_porter/table.css +45 -0
  18. data/app/controllers/data_porter/imports_controller.rb +74 -10
  19. data/app/controllers/data_porter/mapping_templates_controller.rb +85 -0
  20. data/app/javascript/data_porter/mapping_controller.js +86 -0
  21. data/app/javascript/data_porter/progress_controller.js +1 -1
  22. data/app/javascript/data_porter/template_form_controller.js +46 -0
  23. data/app/jobs/data_porter/extract_headers_job.rb +12 -0
  24. data/app/models/data_porter/data_import.rb +8 -2
  25. data/app/models/data_porter/mapping_template.rb +15 -0
  26. data/app/views/data_porter/imports/index.html.erb +9 -8
  27. data/app/views/data_porter/imports/new.html.erb +10 -4
  28. data/app/views/data_porter/imports/show.html.erb +41 -13
  29. data/app/views/data_porter/mapping_templates/_form.html.erb +40 -0
  30. data/app/views/data_porter/mapping_templates/edit.html.erb +11 -0
  31. data/app/views/data_porter/mapping_templates/index.html.erb +42 -0
  32. data/app/views/data_porter/mapping_templates/new.html.erb +11 -0
  33. data/app/views/layouts/data_porter/application.html.erb +162 -0
  34. data/config/routes.rb +3 -0
  35. data/docs/CONFIGURATION.md +81 -0
  36. data/docs/MAPPING.md +44 -0
  37. data/docs/SOURCES.md +94 -0
  38. data/docs/TARGETS.md +176 -0
  39. data/docs/screenshots/mapping.jpg +0 -0
  40. data/lib/data_porter/components/mapping/column_row.rb +52 -0
  41. data/lib/data_porter/components/mapping/form.rb +127 -0
  42. data/lib/data_porter/components/mapping/template_select.rb +35 -0
  43. data/lib/data_porter/components/preview/results_summary.rb +21 -0
  44. data/lib/data_porter/components/preview/summary_cards.rb +32 -0
  45. data/lib/data_porter/components/preview/table.rb +56 -0
  46. data/lib/data_porter/components/progress/bar.rb +35 -0
  47. data/lib/data_porter/components/shared/failure_alert.rb +22 -0
  48. data/lib/data_porter/components/shared/status_badge.rb +18 -0
  49. data/lib/data_porter/components.rb +9 -6
  50. data/lib/data_porter/configuration.rb +1 -1
  51. data/lib/data_porter/engine.rb +7 -1
  52. data/lib/data_porter/orchestrator.rb +21 -1
  53. data/lib/data_porter/sources/base.rb +18 -3
  54. data/lib/data_porter/sources/csv.rb +5 -0
  55. data/lib/data_porter/sources/xlsx.rb +76 -0
  56. data/lib/data_porter/sources.rb +3 -1
  57. data/lib/data_porter/version.rb +1 -1
  58. data/lib/generators/data_porter/install/install_generator.rb +4 -0
  59. data/lib/generators/data_porter/install/templates/create_data_porter_mapping_templates.rb.erb +16 -0
  60. data/lib/generators/data_porter/install/templates/initializer.rb +1 -1
  61. metadata +72 -135
  62. data/.claude/commands/blog-status.md +0 -10
  63. data/.claude/commands/blog.md +0 -109
  64. data/.claude/commands/task-done.md +0 -27
  65. data/.claude/commands/tm/add-dependency.md +0 -58
  66. data/.claude/commands/tm/add-subtask.md +0 -79
  67. data/.claude/commands/tm/add-task.md +0 -81
  68. data/.claude/commands/tm/analyze-complexity.md +0 -124
  69. data/.claude/commands/tm/analyze-project.md +0 -100
  70. data/.claude/commands/tm/auto-implement-tasks.md +0 -100
  71. data/.claude/commands/tm/command-pipeline.md +0 -80
  72. data/.claude/commands/tm/complexity-report.md +0 -120
  73. data/.claude/commands/tm/convert-task-to-subtask.md +0 -74
  74. data/.claude/commands/tm/expand-all-tasks.md +0 -52
  75. data/.claude/commands/tm/expand-task.md +0 -52
  76. data/.claude/commands/tm/fix-dependencies.md +0 -82
  77. data/.claude/commands/tm/help.md +0 -101
  78. data/.claude/commands/tm/init-project-quick.md +0 -49
  79. data/.claude/commands/tm/init-project.md +0 -53
  80. data/.claude/commands/tm/install-taskmaster.md +0 -118
  81. data/.claude/commands/tm/learn.md +0 -106
  82. data/.claude/commands/tm/list-tasks-by-status.md +0 -42
  83. data/.claude/commands/tm/list-tasks-with-subtasks.md +0 -30
  84. data/.claude/commands/tm/list-tasks.md +0 -46
  85. data/.claude/commands/tm/next-task.md +0 -69
  86. data/.claude/commands/tm/parse-prd-with-research.md +0 -51
  87. data/.claude/commands/tm/parse-prd.md +0 -52
  88. data/.claude/commands/tm/project-status.md +0 -67
  89. data/.claude/commands/tm/quick-install-taskmaster.md +0 -23
  90. data/.claude/commands/tm/remove-all-subtasks.md +0 -94
  91. data/.claude/commands/tm/remove-dependency.md +0 -65
  92. data/.claude/commands/tm/remove-subtask.md +0 -87
  93. data/.claude/commands/tm/remove-subtasks.md +0 -89
  94. data/.claude/commands/tm/remove-task.md +0 -110
  95. data/.claude/commands/tm/setup-models.md +0 -52
  96. data/.claude/commands/tm/show-task.md +0 -85
  97. data/.claude/commands/tm/smart-workflow.md +0 -58
  98. data/.claude/commands/tm/sync-readme.md +0 -120
  99. data/.claude/commands/tm/tm-main.md +0 -147
  100. data/.claude/commands/tm/to-cancelled.md +0 -58
  101. data/.claude/commands/tm/to-deferred.md +0 -50
  102. data/.claude/commands/tm/to-done.md +0 -47
  103. data/.claude/commands/tm/to-in-progress.md +0 -39
  104. data/.claude/commands/tm/to-pending.md +0 -35
  105. data/.claude/commands/tm/to-review.md +0 -43
  106. data/.claude/commands/tm/update-single-task.md +0 -122
  107. data/.claude/commands/tm/update-task.md +0 -75
  108. data/.claude/commands/tm/update-tasks-from-id.md +0 -111
  109. data/.claude/commands/tm/validate-dependencies.md +0 -72
  110. data/.claude/commands/tm/view-models.md +0 -52
  111. data/.env.example +0 -12
  112. data/.mcp.json +0 -24
  113. data/.taskmaster/CLAUDE.md +0 -435
  114. data/.taskmaster/config.json +0 -44
  115. data/.taskmaster/docs/prd.txt +0 -2044
  116. data/.taskmaster/state.json +0 -6
  117. data/.taskmaster/tasks/task_001.md +0 -19
  118. data/.taskmaster/tasks/task_002.md +0 -19
  119. data/.taskmaster/tasks/task_003.md +0 -19
  120. data/.taskmaster/tasks/task_004.md +0 -19
  121. data/.taskmaster/tasks/task_005.md +0 -19
  122. data/.taskmaster/tasks/task_006.md +0 -19
  123. data/.taskmaster/tasks/task_007.md +0 -19
  124. data/.taskmaster/tasks/task_008.md +0 -19
  125. data/.taskmaster/tasks/task_009.md +0 -19
  126. data/.taskmaster/tasks/task_010.md +0 -19
  127. data/.taskmaster/tasks/task_011.md +0 -19
  128. data/.taskmaster/tasks/task_012.md +0 -19
  129. data/.taskmaster/tasks/task_013.md +0 -19
  130. data/.taskmaster/tasks/task_014.md +0 -19
  131. data/.taskmaster/tasks/task_015.md +0 -19
  132. data/.taskmaster/tasks/task_016.md +0 -19
  133. data/.taskmaster/tasks/task_017.md +0 -19
  134. data/.taskmaster/tasks/task_018.md +0 -19
  135. data/.taskmaster/tasks/task_019.md +0 -19
  136. data/.taskmaster/tasks/task_020.md +0 -19
  137. data/.taskmaster/tasks/tasks.json +0 -299
  138. data/.taskmaster/templates/example_prd.txt +0 -47
  139. data/.taskmaster/templates/example_prd_rpg.txt +0 -511
  140. data/CLAUDE.md +0 -65
  141. data/config/database.yml +0 -3
  142. data/docs/SPEC.md +0 -2012
  143. data/docs/UI.md +0 -32
  144. data/docs/blog/001-why-build-a-data-import-engine.md +0 -166
  145. data/docs/blog/002-scaffolding-a-rails-engine.md +0 -188
  146. data/docs/blog/003-configuration-dsl.md +0 -222
  147. data/docs/blog/004-store-model-jsonb.md +0 -237
  148. data/docs/blog/005-target-dsl.md +0 -284
  149. data/docs/blog/006-parsing-csv-sources.md +0 -300
  150. data/docs/blog/007-orchestrator.md +0 -247
  151. data/docs/blog/008-actioncable-stimulus.md +0 -376
  152. data/docs/blog/009-phlex-ui-components.md +0 -446
  153. data/docs/blog/010-controllers-routing.md +0 -374
  154. data/docs/blog/011-generators.md +0 -364
  155. data/docs/blog/012-json-api-sources.md +0 -323
  156. data/docs/blog/013-testing-rails-engine.md +0 -618
  157. data/docs/blog/014-dry-run.md +0 -307
  158. data/docs/blog/015-publishing-retro.md +0 -264
  159. data/docs/blog/016-erb-view-templates.md +0 -431
  160. data/docs/blog/017-showcase-final-retro.md +0 -220
  161. data/docs/blog/BACKLOG.md +0 -8
  162. data/docs/blog/SERIES.md +0 -154
  163. data/lib/data_porter/components/failure_alert.rb +0 -20
  164. data/lib/data_porter/components/preview_table.rb +0 -54
  165. data/lib/data_porter/components/progress_bar.rb +0 -33
  166. data/lib/data_porter/components/results_summary.rb +0 -19
  167. data/lib/data_porter/components/status_badge.rb +0 -16
  168. data/lib/data_porter/components/summary_cards.rb +0 -30
@@ -0,0 +1,81 @@
1
+ # Configuration
2
+
3
+ All options are set in `config/initializers/data_porter.rb`:
4
+
5
+ ```ruby
6
+ DataPorter.configure do |config|
7
+ # Parent controller for the engine's controllers to inherit from.
8
+ # Controls authentication, layouts, and helpers.
9
+ config.parent_controller = "ApplicationController"
10
+
11
+ # ActiveJob queue name for import jobs.
12
+ config.queue_name = :imports
13
+
14
+ # ActiveStorage service for uploaded files.
15
+ config.storage_service = :local
16
+
17
+ # ActionCable channel prefix.
18
+ config.cable_channel_prefix = "data_porter"
19
+
20
+ # Context builder: inject business data into targets.
21
+ # Receives the current controller instance.
22
+ config.context_builder = ->(controller) {
23
+ OpenStruct.new(user: controller.current_user)
24
+ }
25
+
26
+ # Maximum number of records displayed in preview.
27
+ config.preview_limit = 500
28
+
29
+ # Enabled source types.
30
+ config.enabled_sources = %i[csv json api xlsx]
31
+ end
32
+ ```
33
+
34
+ ## Options reference
35
+
36
+ | Option | Default | Description |
37
+ |---|---|---|
38
+ | `parent_controller` | `"ApplicationController"` | Controller class the engine inherits from |
39
+ | `queue_name` | `:imports` | ActiveJob queue for import jobs |
40
+ | `storage_service` | `:local` | ActiveStorage service name |
41
+ | `cable_channel_prefix` | `"data_porter"` | ActionCable stream prefix |
42
+ | `context_builder` | `nil` | Lambda receiving the controller, returns context passed to target methods |
43
+ | `preview_limit` | `500` | Max records shown in the preview step |
44
+ | `enabled_sources` | `%i[csv json api xlsx]` | Source types available in the UI |
45
+
46
+ ## Authentication
47
+
48
+ The engine inherits authentication from `parent_controller`. Set it to your authenticated base controller:
49
+
50
+ ```ruby
51
+ config.parent_controller = "Admin::BaseController"
52
+ ```
53
+
54
+ All engine routes will require the same authentication as your base controller.
55
+
56
+ ## Context builder
57
+
58
+ The `context_builder` lambda lets you inject business data (current user, tenant, permissions) into target methods (`persist`, `after_import`, `on_error`):
59
+
60
+ ```ruby
61
+ config.context_builder = ->(controller) {
62
+ OpenStruct.new(
63
+ user: controller.current_user,
64
+ organization: controller.current_organization
65
+ )
66
+ }
67
+ ```
68
+
69
+ The returned object is available as `context` in all target instance methods.
70
+
71
+ ## Real-time updates
72
+
73
+ DataPorter broadcasts import progress via ActionCable. The channel streams on:
74
+
75
+ ```
76
+ #{cable_channel_prefix}/imports/#{import_id}
77
+ ```
78
+
79
+ The default prefix is `data_porter`, so a typical stream name is `data_porter/imports/42`.
80
+
81
+ The engine ships with a Stimulus controller that automatically subscribes to the channel and updates a progress bar during parsing and importing. If ActionCable is unavailable, it falls back to polling every 3 seconds.
data/docs/MAPPING.md ADDED
@@ -0,0 +1,44 @@
1
+ # Column Mapping
2
+
3
+ For file-based sources (CSV/XLSX), DataPorter adds an interactive mapping step between upload and parsing. Users see their file's actual column headers and map each one to a target field via dropdowns.
4
+
5
+ ```
6
+ File Header Target Field
7
+ +-----------+ +---------------+
8
+ | Prenom | -> | First Name v |
9
+ +-----------+ +---------------+
10
+ +-----------+ +---------------+
11
+ | Nom | -> | Last Name v |
12
+ +-----------+ +---------------+
13
+ ```
14
+
15
+ Dropdowns are pre-filled from the Target's `csv_mapping` when headers match. Users can adjust any mapping before continuing to the preview step.
16
+
17
+ ## Required fields
18
+
19
+ Required target fields are marked with `*` in the dropdown labels. If any required field is left unmapped, a warning banner appears listing the missing fields. This validation is client-side only -- it warns but does not block submission.
20
+
21
+ ## Duplicate detection
22
+
23
+ If two file headers are mapped to the same target field, the affected rows are highlighted with an orange border and a warning message appears. This helps catch accidental duplicate mappings before parsing.
24
+
25
+ ## Mapping Templates
26
+
27
+ Mappings can be saved as reusable templates. When starting a new import, users select a saved template from a dropdown to auto-fill all column mappings at once. Templates are stored per-target, so each import type has its own template library.
28
+
29
+ ### Managing templates
30
+
31
+ - **Inline**: Check "Save as template" in the mapping form and give it a name
32
+ - **CRUD**: Use the "Mapping Templates" link on the imports index page to create, edit, and delete templates
33
+
34
+ When a template is loaded, the "Save as template" checkbox is hidden since the user is already working from an existing template.
35
+
36
+ ## Mapping Priority
37
+
38
+ When parsing, mappings are resolved in priority order:
39
+
40
+ 1. **User mapping** -- from the mapping UI (`config["column_mapping"]`)
41
+ 2. **Code mapping** -- from the Target DSL (`csv_mapping`)
42
+ 3. **Auto-map** -- parameterize headers to match column names
43
+
44
+ Non-file sources (JSON, API) skip the mapping step entirely.
data/docs/SOURCES.md ADDED
@@ -0,0 +1,94 @@
1
+ # Sources
2
+
3
+ DataPorter supports four source types. Each source reads data from a different format and feeds it through the same parsing pipeline.
4
+
5
+ ## CSV
6
+
7
+ Upload a CSV file. Headers are extracted automatically and presented in the [column mapping](MAPPING.md) step. Configure header mappings with `csv_mapping` in your [Target](TARGETS.md) when file headers don't match your column names.
8
+
9
+ Custom separator:
10
+
11
+ ```ruby
12
+ import.config = { "separator" => ";" }
13
+ ```
14
+
15
+ ## XLSX
16
+
17
+ Upload an Excel `.xlsx` file. Uses the same `csv_mapping` for header-to-column mapping as CSV. By default the first sheet is parsed; select a different sheet via config:
18
+
19
+ ```ruby
20
+ import.config = { "sheet_index" => 1 }
21
+ ```
22
+
23
+ Powered by [creek](https://github.com/pythonicrubyist/creek) for streaming, memory-efficient parsing.
24
+
25
+ ## JSON
26
+
27
+ Upload a JSON file. Use `json_root` in your Target to specify the path to the records array. Raw JSON arrays are supported without `json_root`.
28
+
29
+ ```ruby
30
+ json_root "data.users"
31
+ ```
32
+
33
+ Given `{ "data": { "users": [...] } }`, records are extracted from `data.users`.
34
+
35
+ ## API
36
+
37
+ Fetch records from an external API endpoint. No file upload is needed -- the engine calls the API directly.
38
+
39
+ ### Basic usage
40
+
41
+ ```ruby
42
+ api_config do
43
+ endpoint "https://api.example.com/data"
44
+ headers({ "Authorization" => "Bearer token" })
45
+ response_root "results"
46
+ end
47
+ ```
48
+
49
+ | Option | Type | Description |
50
+ |---|---|---|
51
+ | `endpoint` | String or Proc | URL to fetch records from |
52
+ | `headers` | Hash or Proc | HTTP headers sent with the request |
53
+ | `response_root` | String | Key in the JSON response containing the records array (omit for top-level arrays) |
54
+
55
+ ### Dynamic endpoints and headers
56
+
57
+ Both `endpoint` and `headers` accept lambdas for runtime values. The endpoint lambda receives the import's `config` hash:
58
+
59
+ ```ruby
60
+ api_config do
61
+ endpoint ->(params) { "https://api.example.com/events?page=#{params[:page]}" }
62
+ headers -> { { "Authorization" => "Bearer #{ENV['API_TOKEN']}" } }
63
+ response_root "data"
64
+ end
65
+ ```
66
+
67
+ ### Full example
68
+
69
+ ```ruby
70
+ class EventTarget < DataPorter::Target
71
+ label "Events"
72
+ model_name "Event"
73
+ sources :api
74
+
75
+ api_config do
76
+ endpoint "https://api.example.com/events"
77
+ headers -> { { "Authorization" => "Bearer #{ENV['EVENTS_API_KEY']}" } }
78
+ response_root "events"
79
+ end
80
+
81
+ columns do
82
+ column :name, type: :string, required: true
83
+ column :date, type: :date
84
+ column :venue, type: :string
85
+ column :capacity, type: :integer
86
+ end
87
+
88
+ def persist(record, context:)
89
+ Event.create!(record.attributes)
90
+ end
91
+ end
92
+ ```
93
+
94
+ When a user creates an import with source type **API**, the engine skips file upload entirely, calls the configured endpoint, parses the JSON response, and feeds the records through the same preview/validate/import pipeline as file-based sources.
data/docs/TARGETS.md ADDED
@@ -0,0 +1,176 @@
1
+ # Targets
2
+
3
+ Targets are plain Ruby classes in `app/importers/` that inherit from `DataPorter::Target`. Each target defines one import type: its columns, sources, mappings, and persistence logic.
4
+
5
+ ## Generator
6
+
7
+ ```bash
8
+ bin/rails generate data_porter:target ModelName column:type[:required] ...
9
+ ```
10
+
11
+ Examples:
12
+
13
+ ```bash
14
+ bin/rails generate data_porter:target User email:string:required name:string age:integer
15
+ bin/rails generate data_porter:target Product name:string price:decimal
16
+ ```
17
+
18
+ Column format: `name:type[:required]`
19
+
20
+ Supported types: `string`, `integer`, `decimal`, `boolean`, `date`.
21
+
22
+ ## Class-level DSL
23
+
24
+ ```ruby
25
+ class OrderTarget < DataPorter::Target
26
+ label "Orders"
27
+ model_name "Order"
28
+ icon "fas fa-shopping-cart"
29
+ sources :csv, :json, :api, :xlsx
30
+
31
+ columns do
32
+ column :order_number, type: :string, required: true
33
+ column :total, type: :decimal
34
+ column :placed_at, type: :date
35
+ column :active, type: :boolean
36
+ column :quantity, type: :integer
37
+ end
38
+
39
+ csv_mapping do
40
+ map "Order #" => :order_number
41
+ map "Total ($)" => :total
42
+ end
43
+
44
+ json_root "data.orders"
45
+
46
+ api_config do
47
+ endpoint "https://api.example.com/orders"
48
+ headers({ "Authorization" => "Bearer token" })
49
+ response_root "data.orders"
50
+ end
51
+
52
+ deduplicate_by :order_number
53
+
54
+ dry_run_enabled
55
+ end
56
+ ```
57
+
58
+ ### `label(value)`
59
+
60
+ Human-readable name shown in the UI.
61
+
62
+ ### `model_name(value)`
63
+
64
+ The ActiveRecord model name this target imports into (for display purposes).
65
+
66
+ ### `icon(value)`
67
+
68
+ CSS icon class (e.g. FontAwesome) shown in the UI.
69
+
70
+ ### `sources(*types)`
71
+
72
+ Accepted source types: `:csv`, `:json`, `:api`, `:xlsx`.
73
+
74
+ ### `columns { ... }`
75
+
76
+ Defines the expected columns for this import. Each column accepts:
77
+
78
+ | Parameter | Type | Default | Description |
79
+ |---|---|---|---|
80
+ | `name` | Symbol | (required) | Column identifier |
81
+ | `type` | Symbol | `:string` | One of `:string`, `:integer`, `:decimal`, `:boolean`, `:date` |
82
+ | `required` | Boolean | `false` | Whether the column must have a value |
83
+ | `label` | String | Humanized name | Display label in the preview |
84
+
85
+ ### `csv_mapping { ... }`
86
+
87
+ Maps CSV/XLSX header names to column names when they don't match:
88
+
89
+ ```ruby
90
+ csv_mapping do
91
+ map "First Name" => :first_name
92
+ map "E-mail" => :email
93
+ end
94
+ ```
95
+
96
+ ### `json_root(path)`
97
+
98
+ Dot-separated path to the array of records within a JSON document:
99
+
100
+ ```ruby
101
+ json_root "data.users"
102
+ ```
103
+
104
+ Given `{ "data": { "users": [...] } }`, records are extracted from `data.users`.
105
+
106
+ ### `api_config { ... }`
107
+
108
+ See [Sources: API](SOURCES.md#api) for full documentation.
109
+
110
+ ### `deduplicate_by(*keys)`
111
+
112
+ Skip records that share the same value(s) for the given column(s):
113
+
114
+ ```ruby
115
+ deduplicate_by :email
116
+ deduplicate_by :first_name, :last_name
117
+ ```
118
+
119
+ ### `dry_run_enabled`
120
+
121
+ Enables dry run mode for this target. A "Dry Run" button appears in the preview step. Dry run executes the full import pipeline (transform, validate, persist) inside a rolled-back transaction, giving a validation report without modifying the database.
122
+
123
+ ## Instance Methods
124
+
125
+ Override these in your target to customize behavior.
126
+
127
+ ### `transform(record)`
128
+
129
+ Transform a record before validation. Must return the (modified) record.
130
+
131
+ ```ruby
132
+ def transform(record)
133
+ record.attributes["email"] = record.attributes["email"]&.downcase
134
+ record
135
+ end
136
+ ```
137
+
138
+ ### `validate(record)`
139
+
140
+ Add custom validation errors to a record:
141
+
142
+ ```ruby
143
+ def validate(record)
144
+ record.add_error("Email is invalid") unless record.attributes["email"]&.include?("@")
145
+ end
146
+ ```
147
+
148
+ ### `persist(record, context:)`
149
+
150
+ **Required.** Save the record to your database. Raises `NotImplementedError` if not overridden.
151
+
152
+ ```ruby
153
+ def persist(record, context:)
154
+ User.create!(record.attributes)
155
+ end
156
+ ```
157
+
158
+ ### `after_import(results, context:)`
159
+
160
+ Called once after all records have been processed:
161
+
162
+ ```ruby
163
+ def after_import(results, context:)
164
+ AdminMailer.import_complete(context.user, results).deliver_later
165
+ end
166
+ ```
167
+
168
+ ### `on_error(record, error, context:)`
169
+
170
+ Called when a record fails to import:
171
+
172
+ ```ruby
173
+ def on_error(record, error, context:)
174
+ Sentry.capture_exception(error, extra: { record: record.attributes })
175
+ end
176
+ ```
Binary file
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Components
5
+ module Mapping
6
+ class ColumnRow < Base
7
+ def initialize(file_header:, target_fields:, selected: nil)
8
+ super()
9
+ @file_header = file_header
10
+ @target_fields = target_fields
11
+ @selected = selected
12
+ end
13
+
14
+ def view_template
15
+ div(class: "dp-mapping-row") do
16
+ render_header
17
+ render_arrow
18
+ render_select
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def render_header
25
+ span(class: "dp-mapping-row__header") { @file_header }
26
+ end
27
+
28
+ def render_arrow
29
+ span(class: "dp-mapping-row__arrow") { "→" }
30
+ end
31
+
32
+ def render_select
33
+ select(
34
+ name: "column_mapping[#{@file_header}]",
35
+ class: "dp-select dp-mapping-row__select",
36
+ data_data_porter__mapping_target: "columnSelect",
37
+ data_action: "change->data-porter--mapping#onChange"
38
+ ) do
39
+ option(value: "") { "Skip this column" }
40
+ @target_fields.each { |label, value, required| render_field_option(label, value, required) }
41
+ end
42
+ end
43
+
44
+ def render_field_option(label, value, required)
45
+ attrs = { value: value, selected: value == @selected }
46
+ attrs[:data_required] = "true" if required
47
+ option(**attrs) { required ? "#{label} *" : label }
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module DataPorter
6
+ module Components
7
+ module Mapping
8
+ class Form < Base
9
+ def initialize(import:, action_url:, **options)
10
+ super()
11
+ @import = import
12
+ @action_url = action_url
13
+ @csrf_token = options[:csrf_token]
14
+ @file_headers = options.fetch(:file_headers)
15
+ @target_columns = options.fetch(:target_columns)
16
+ @templates = options.fetch(:templates)
17
+ @default_mapping = options.fetch(:default_mapping)
18
+ end
19
+
20
+ def view_template
21
+ form(
22
+ action: @action_url,
23
+ method: "post",
24
+ class: "dp-mapping-form",
25
+ data_controller: "data-porter--mapping",
26
+ data_data_porter__mapping_required_columns_value: required_columns_json
27
+ ) { render_form_body }
28
+ end
29
+
30
+ private
31
+
32
+ def render_form_body
33
+ render_csrf_token
34
+ render_method_override
35
+ render_template_section
36
+ render_warning("required")
37
+ render_warning("duplicate")
38
+ render_column_rows
39
+ render_save_template
40
+ render_actions
41
+ end
42
+
43
+ def render_csrf_token
44
+ return unless @csrf_token
45
+
46
+ input(type: "hidden", name: "authenticity_token", value: @csrf_token)
47
+ end
48
+
49
+ def render_method_override
50
+ input(type: "hidden", name: "_method", value: "patch")
51
+ end
52
+
53
+ def render_template_section
54
+ return if @templates.empty?
55
+
56
+ div(class: "dp-field") do
57
+ label(class: "dp-label") { "Load Template" }
58
+ render TemplateSelect.new(templates: @templates)
59
+ end
60
+ end
61
+
62
+ def render_warning(type)
63
+ div(
64
+ class: "dp-mapping-#{type}-warning",
65
+ data_data_porter__mapping_target: "#{type}Warning",
66
+ style: "display: none;"
67
+ ) { "" }
68
+ end
69
+
70
+ def render_column_rows
71
+ div(class: "dp-mapping-rows") do
72
+ @file_headers.each { |header| render_row(header) }
73
+ end
74
+ end
75
+
76
+ def render_row(header)
77
+ selected = @default_mapping[header]
78
+ render ColumnRow.new(
79
+ file_header: header,
80
+ target_fields: @target_columns,
81
+ selected: selected
82
+ )
83
+ end
84
+
85
+ def render_save_template
86
+ div(
87
+ class: "dp-field",
88
+ style: "margin-top: 1.5rem;",
89
+ data_data_porter__mapping_target: "saveTemplate"
90
+ ) do
91
+ render_template_checkbox
92
+ render_template_name_input
93
+ end
94
+ end
95
+
96
+ def render_template_checkbox
97
+ label(style: "display: flex; align-items: center; gap: 0.5rem;") do
98
+ input(type: "checkbox", name: "save_template", value: "1")
99
+ span { "Save as template" }
100
+ end
101
+ end
102
+
103
+ def render_template_name_input
104
+ input(
105
+ type: "text", name: "template_name",
106
+ placeholder: "Template name",
107
+ class: "dp-select",
108
+ style: "margin-top: 0.5rem;"
109
+ )
110
+ end
111
+
112
+ def render_actions
113
+ div(class: "dp-actions") do
114
+ button(type: "submit", class: "dp-btn dp-btn--primary") { "Continue" }
115
+ end
116
+ end
117
+
118
+ def required_columns_json
119
+ @target_columns
120
+ .select { |_, _, required| required }
121
+ .map { |label, name, _| { label: label, name: name } }
122
+ .to_json
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Components
5
+ module Mapping
6
+ class TemplateSelect < Base
7
+ def initialize(templates:, selected_id: nil)
8
+ super()
9
+ @templates = templates
10
+ @selected_id = selected_id
11
+ end
12
+
13
+ def view_template
14
+ select(
15
+ class: "dp-select dp-mapping-template",
16
+ data_action: "change->data-porter--mapping#loadTemplate"
17
+ ) do
18
+ option(value: "") { "Select a template..." }
19
+ @templates.each { |t| render_option(t) }
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def render_option(template)
26
+ option(
27
+ value: template.id.to_s,
28
+ selected: template.id == @selected_id,
29
+ data_mapping: template.mapping.to_json
30
+ ) { template.name }
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Components
5
+ module Preview
6
+ class ResultsSummary < Base
7
+ def initialize(report:)
8
+ super()
9
+ @report = report
10
+ end
11
+
12
+ def view_template
13
+ div(class: "dp-results") do
14
+ p { "Created: #{@report.imported_count}" }
15
+ p { "Errors: #{@report.errored_count}" }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Components
5
+ module Preview
6
+ class SummaryCards < Base
7
+ def initialize(report:)
8
+ super()
9
+ @report = report
10
+ end
11
+
12
+ def view_template
13
+ div(class: "dp-summary-cards") do
14
+ card("dp-card--complete", @report.complete_count, "Ready")
15
+ card("dp-card--partial", @report.partial_count, "Incomplete")
16
+ card("dp-card--missing", @report.missing_count, "Missing")
17
+ card("dp-card--duplicate", @report.duplicate_count, "Duplicates")
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def card(css_class, count, label)
24
+ div(class: "dp-card #{css_class}") do
25
+ strong { count.to_s }
26
+ plain " #{label}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end