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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -1
- data/README.md +63 -386
- data/ROADMAP.md +89 -0
- data/app/assets/javascripts/data_porter/stimulus.min.js +2 -0
- data/app/assets/javascripts/data_porter/turbo.min.js +29 -0
- data/app/assets/stylesheets/data_porter/alerts.css +25 -0
- data/app/assets/stylesheets/data_porter/application.css +12 -646
- data/app/assets/stylesheets/data_porter/badges.css +73 -0
- data/app/assets/stylesheets/data_porter/base.css +56 -0
- data/app/assets/stylesheets/data_porter/cards.css +60 -0
- data/app/assets/stylesheets/data_porter/layout.css +128 -0
- data/app/assets/stylesheets/data_porter/mapping.css +79 -0
- data/app/assets/stylesheets/data_porter/modal.css +49 -0
- data/app/assets/stylesheets/data_porter/preview.css +24 -0
- data/app/assets/stylesheets/data_porter/progress.css +37 -0
- data/app/assets/stylesheets/data_porter/table.css +45 -0
- data/app/controllers/data_porter/imports_controller.rb +74 -10
- data/app/controllers/data_porter/mapping_templates_controller.rb +85 -0
- data/app/javascript/data_porter/mapping_controller.js +86 -0
- data/app/javascript/data_porter/progress_controller.js +1 -1
- data/app/javascript/data_porter/template_form_controller.js +46 -0
- data/app/jobs/data_porter/extract_headers_job.rb +12 -0
- data/app/models/data_porter/data_import.rb +8 -2
- data/app/models/data_porter/mapping_template.rb +15 -0
- data/app/views/data_porter/imports/index.html.erb +9 -8
- data/app/views/data_porter/imports/new.html.erb +10 -4
- data/app/views/data_porter/imports/show.html.erb +41 -13
- data/app/views/data_porter/mapping_templates/_form.html.erb +40 -0
- data/app/views/data_porter/mapping_templates/edit.html.erb +11 -0
- data/app/views/data_porter/mapping_templates/index.html.erb +42 -0
- data/app/views/data_porter/mapping_templates/new.html.erb +11 -0
- data/app/views/layouts/data_porter/application.html.erb +162 -0
- data/config/routes.rb +3 -0
- data/docs/CONFIGURATION.md +81 -0
- data/docs/MAPPING.md +44 -0
- data/docs/SOURCES.md +94 -0
- data/docs/TARGETS.md +176 -0
- data/docs/screenshots/mapping.jpg +0 -0
- data/lib/data_porter/components/mapping/column_row.rb +52 -0
- data/lib/data_porter/components/mapping/form.rb +127 -0
- data/lib/data_porter/components/mapping/template_select.rb +35 -0
- data/lib/data_porter/components/preview/results_summary.rb +21 -0
- data/lib/data_porter/components/preview/summary_cards.rb +32 -0
- data/lib/data_porter/components/preview/table.rb +56 -0
- data/lib/data_porter/components/progress/bar.rb +35 -0
- data/lib/data_porter/components/shared/failure_alert.rb +22 -0
- data/lib/data_porter/components/shared/status_badge.rb +18 -0
- data/lib/data_porter/components.rb +9 -6
- data/lib/data_porter/configuration.rb +1 -1
- data/lib/data_porter/engine.rb +7 -1
- data/lib/data_porter/orchestrator.rb +21 -1
- data/lib/data_porter/sources/base.rb +18 -3
- data/lib/data_porter/sources/csv.rb +5 -0
- data/lib/data_porter/sources/xlsx.rb +76 -0
- data/lib/data_porter/sources.rb +3 -1
- data/lib/data_porter/version.rb +1 -1
- data/lib/generators/data_porter/install/install_generator.rb +4 -0
- data/lib/generators/data_porter/install/templates/create_data_porter_mapping_templates.rb.erb +16 -0
- data/lib/generators/data_porter/install/templates/initializer.rb +1 -1
- metadata +72 -135
- data/.claude/commands/blog-status.md +0 -10
- data/.claude/commands/blog.md +0 -109
- data/.claude/commands/task-done.md +0 -27
- data/.claude/commands/tm/add-dependency.md +0 -58
- data/.claude/commands/tm/add-subtask.md +0 -79
- data/.claude/commands/tm/add-task.md +0 -81
- data/.claude/commands/tm/analyze-complexity.md +0 -124
- data/.claude/commands/tm/analyze-project.md +0 -100
- data/.claude/commands/tm/auto-implement-tasks.md +0 -100
- data/.claude/commands/tm/command-pipeline.md +0 -80
- data/.claude/commands/tm/complexity-report.md +0 -120
- data/.claude/commands/tm/convert-task-to-subtask.md +0 -74
- data/.claude/commands/tm/expand-all-tasks.md +0 -52
- data/.claude/commands/tm/expand-task.md +0 -52
- data/.claude/commands/tm/fix-dependencies.md +0 -82
- data/.claude/commands/tm/help.md +0 -101
- data/.claude/commands/tm/init-project-quick.md +0 -49
- data/.claude/commands/tm/init-project.md +0 -53
- data/.claude/commands/tm/install-taskmaster.md +0 -118
- data/.claude/commands/tm/learn.md +0 -106
- data/.claude/commands/tm/list-tasks-by-status.md +0 -42
- data/.claude/commands/tm/list-tasks-with-subtasks.md +0 -30
- data/.claude/commands/tm/list-tasks.md +0 -46
- data/.claude/commands/tm/next-task.md +0 -69
- data/.claude/commands/tm/parse-prd-with-research.md +0 -51
- data/.claude/commands/tm/parse-prd.md +0 -52
- data/.claude/commands/tm/project-status.md +0 -67
- data/.claude/commands/tm/quick-install-taskmaster.md +0 -23
- data/.claude/commands/tm/remove-all-subtasks.md +0 -94
- data/.claude/commands/tm/remove-dependency.md +0 -65
- data/.claude/commands/tm/remove-subtask.md +0 -87
- data/.claude/commands/tm/remove-subtasks.md +0 -89
- data/.claude/commands/tm/remove-task.md +0 -110
- data/.claude/commands/tm/setup-models.md +0 -52
- data/.claude/commands/tm/show-task.md +0 -85
- data/.claude/commands/tm/smart-workflow.md +0 -58
- data/.claude/commands/tm/sync-readme.md +0 -120
- data/.claude/commands/tm/tm-main.md +0 -147
- data/.claude/commands/tm/to-cancelled.md +0 -58
- data/.claude/commands/tm/to-deferred.md +0 -50
- data/.claude/commands/tm/to-done.md +0 -47
- data/.claude/commands/tm/to-in-progress.md +0 -39
- data/.claude/commands/tm/to-pending.md +0 -35
- data/.claude/commands/tm/to-review.md +0 -43
- data/.claude/commands/tm/update-single-task.md +0 -122
- data/.claude/commands/tm/update-task.md +0 -75
- data/.claude/commands/tm/update-tasks-from-id.md +0 -111
- data/.claude/commands/tm/validate-dependencies.md +0 -72
- data/.claude/commands/tm/view-models.md +0 -52
- data/.env.example +0 -12
- data/.mcp.json +0 -24
- data/.taskmaster/CLAUDE.md +0 -435
- data/.taskmaster/config.json +0 -44
- data/.taskmaster/docs/prd.txt +0 -2044
- data/.taskmaster/state.json +0 -6
- data/.taskmaster/tasks/task_001.md +0 -19
- data/.taskmaster/tasks/task_002.md +0 -19
- data/.taskmaster/tasks/task_003.md +0 -19
- data/.taskmaster/tasks/task_004.md +0 -19
- data/.taskmaster/tasks/task_005.md +0 -19
- data/.taskmaster/tasks/task_006.md +0 -19
- data/.taskmaster/tasks/task_007.md +0 -19
- data/.taskmaster/tasks/task_008.md +0 -19
- data/.taskmaster/tasks/task_009.md +0 -19
- data/.taskmaster/tasks/task_010.md +0 -19
- data/.taskmaster/tasks/task_011.md +0 -19
- data/.taskmaster/tasks/task_012.md +0 -19
- data/.taskmaster/tasks/task_013.md +0 -19
- data/.taskmaster/tasks/task_014.md +0 -19
- data/.taskmaster/tasks/task_015.md +0 -19
- data/.taskmaster/tasks/task_016.md +0 -19
- data/.taskmaster/tasks/task_017.md +0 -19
- data/.taskmaster/tasks/task_018.md +0 -19
- data/.taskmaster/tasks/task_019.md +0 -19
- data/.taskmaster/tasks/task_020.md +0 -19
- data/.taskmaster/tasks/tasks.json +0 -299
- data/.taskmaster/templates/example_prd.txt +0 -47
- data/.taskmaster/templates/example_prd_rpg.txt +0 -511
- data/CLAUDE.md +0 -65
- data/config/database.yml +0 -3
- data/docs/SPEC.md +0 -2012
- data/docs/UI.md +0 -32
- data/docs/blog/001-why-build-a-data-import-engine.md +0 -166
- data/docs/blog/002-scaffolding-a-rails-engine.md +0 -188
- data/docs/blog/003-configuration-dsl.md +0 -222
- data/docs/blog/004-store-model-jsonb.md +0 -237
- data/docs/blog/005-target-dsl.md +0 -284
- data/docs/blog/006-parsing-csv-sources.md +0 -300
- data/docs/blog/007-orchestrator.md +0 -247
- data/docs/blog/008-actioncable-stimulus.md +0 -376
- data/docs/blog/009-phlex-ui-components.md +0 -446
- data/docs/blog/010-controllers-routing.md +0 -374
- data/docs/blog/011-generators.md +0 -364
- data/docs/blog/012-json-api-sources.md +0 -323
- data/docs/blog/013-testing-rails-engine.md +0 -618
- data/docs/blog/014-dry-run.md +0 -307
- data/docs/blog/015-publishing-retro.md +0 -264
- data/docs/blog/016-erb-view-templates.md +0 -431
- data/docs/blog/017-showcase-final-retro.md +0 -220
- data/docs/blog/BACKLOG.md +0 -8
- data/docs/blog/SERIES.md +0 -154
- data/lib/data_porter/components/failure_alert.rb +0 -20
- data/lib/data_porter/components/preview_table.rb +0 -54
- data/lib/data_porter/components/progress_bar.rb +0 -33
- data/lib/data_porter/components/results_summary.rb +0 -19
- data/lib/data_porter/components/status_badge.rb +0 -16
- 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
|