data_porter 0.5.0 → 0.9.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +12 -6
  4. data/app/assets/javascripts/data_porter/import_form_controller.js +126 -0
  5. data/app/assets/stylesheets/data_porter/table.css +45 -0
  6. data/app/controllers/data_porter/concerns/import_validation.rb +47 -0
  7. data/app/controllers/data_porter/concerns/mapping_management.rb +43 -0
  8. data/app/controllers/data_porter/concerns/record_pagination.rb +19 -0
  9. data/app/controllers/data_porter/imports_controller.rb +14 -48
  10. data/app/views/data_porter/imports/index.html.erb +59 -115
  11. data/app/views/data_porter/imports/new.html.erb +51 -2
  12. data/app/views/data_porter/imports/show.html.erb +10 -0
  13. data/app/views/layouts/data_porter/application.html.erb +17 -146
  14. data/docs/CONFIGURATION.md +28 -6
  15. data/docs/ROADMAP.md +28 -0
  16. data/docs/TARGETS.md +54 -3
  17. data/lib/data_porter/components/shared/pagination.rb +53 -0
  18. data/lib/data_porter/components.rb +1 -0
  19. data/lib/data_porter/dsl/param.rb +32 -0
  20. data/lib/data_porter/engine.rb +4 -0
  21. data/lib/data_porter/orchestrator/dry_runner.rb +30 -0
  22. data/lib/data_porter/orchestrator/importer.rb +41 -0
  23. data/lib/data_porter/orchestrator/record_builder.rb +38 -0
  24. data/lib/data_porter/orchestrator.rb +16 -83
  25. data/lib/data_porter/registry.rb +21 -1
  26. data/lib/data_porter/target.rb +17 -1
  27. data/lib/data_porter/version.rb +1 -1
  28. metadata +14 -4
  29. /data/app/{javascript → assets/javascripts}/data_porter/mapping_controller.js +0 -0
  30. /data/app/{javascript → assets/javascripts}/data_porter/progress_controller.js +0 -0
  31. /data/app/{javascript → assets/javascripts}/data_porter/template_form_controller.js +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e99d5974ba60bb61902d1dcf98ad74d940fd82344ec88404fa55e2414cee3fc0
4
- data.tar.gz: 7166547614a1a5942648e85d900d30abe053b4ba4f066811bb71389b83359531
3
+ metadata.gz: 20e2b579cf4078a611095abd155090d7754e1e33e044a022136c846edb793bcb
4
+ data.tar.gz: 58e1bcad055c198aab6ccb71ddbbb5b219d5b82a7a94a34fb8c064f109d37878
5
5
  SHA512:
6
- metadata.gz: 66860e2fb095ccd684e9899d8209fa4ae26b4c79595792bc3ebc4714a34b4a0605fab7e8c1e3a3e7698ad2b40e1cfbd06b58e7b80c995fab50091c88e547a2f6
7
- data.tar.gz: 3864e610fbfce43d2ed67fdc5eab5b8d41920f6f79644fa549875f9bac8a96d2eae88cc358af8b74c85c2b374dc7486c728da9afb9ee786027de327d726e1b61
6
+ metadata.gz: 9929013331242330b53fe9f5e4b4940600a339c7e2d723551e864a969406cd2c516e70a10c95eff25c820b7f7e5904b63da4156b473d140e261d3a65ed9811eb
7
+ data.tar.gz: c9be1bbff65d0da5b36b5a837f590514ed29eb911a4ba527baa3e2f228f2d27252a4f4ff9bff3af579ac6674b6be19b32f0f4bf23aab992d42291877ea019e56
data/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.9.0] - 2026-02-07
9
+
10
+ ### Added
11
+
12
+ - **Import params DSL** -- Targets declare extra form fields via `params { param :hotel_id, type: :select, ... }` with support for `:select`, `:text`, `:number`, and `:hidden` types
13
+ - **`DSL::Param` struct** -- Stores param definitions (name, type, required, label, collection, default) with type validation
14
+ - **`import_params` accessor** -- Available in all target instance methods (`persist`, `transform`, `validate`, `after_import`, `on_error`), defaults to `{}`
15
+ - **Dynamic form rendering** -- Stimulus controller and inline JS dynamically render param fields when a target is selected, with default values and required indicators
16
+ - **Required params validation** -- Controller validates required import params on create, with error messages per missing field
17
+ - **Registry params serialization** -- `Registry.available` includes serialized param definitions with collection lambdas evaluated at call time
18
+
19
+ ### Changed
20
+
21
+ - Controller concerns extracted into `ImportValidation`, `MappingManagement`, `RecordPagination`
22
+ - Orchestrator logic extracted into `RecordBuilder`, `Importer`, `DryRunner` modules
23
+ - Inline JS in `new.html.erb` extracted for consistency
24
+ - 354 RSpec examples (up from 313), 0 failures
25
+
26
+ ## [0.6.0] - 2026-02-07
27
+
28
+ ### Added
29
+
30
+ - **Records pagination** -- Preview and completed pages paginate records at 50 per page with Previous/Next navigation
31
+ - **Pagination component** -- Phlex `Shared::Pagination` with disabled states on first/last page and anchor scrolling to keep viewport on the table
32
+
33
+ ### Changed
34
+
35
+ - 313 RSpec examples (up from 300), 0 failures
36
+
8
37
  ## [0.5.0] - 2026-02-07
9
38
 
10
39
  ### Added
data/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # DataPorter
2
2
 
3
- > [!WARNING]
4
- > This gem is under active development and not yet production-ready. APIs and features may change without notice.
3
+ > [!CAUTION]
4
+ > **This gem is under active development and not yet production-ready.**
5
+ > APIs and features may change without notice. Use at your own risk.
5
6
 
6
7
  A mountable Rails engine for data import workflows: **Upload**, **Map**, **Preview**, **Import**.
7
8
 
@@ -23,9 +24,12 @@ Supports CSV, JSON, XLSX, and API sources with a declarative DSL for defining im
23
24
  - **4 source types** -- CSV, XLSX, JSON, and API with a unified parsing pipeline
24
25
  - **Interactive column mapping** -- Drag-free UI to match file headers to target fields ([docs](docs/MAPPING.md))
25
26
  - **Mapping templates** -- Save and reuse column mappings across imports ([docs](docs/MAPPING.md#mapping-templates))
26
- - **Real-time progress** -- ActionCable updates with polling fallback
27
+ - **Real-time progress** -- JSON polling with animated progress bar, no ActionCable required
27
28
  - **Dry run mode** -- Validate against the database without persisting
28
29
  - **Standalone UI** -- Self-contained layout with Turbo Drive and Stimulus, no host app dependencies
30
+ - **Import params** -- Declare extra form fields (select, text, number, hidden) per target for scoped imports ([docs](docs/TARGETS.md#params--))
31
+ - **Per-target source filtering** -- Each target declares its allowed sources, the UI filters accordingly
32
+ - **Import deletion & auto-purge** -- Delete imports from the UI, or schedule `rake data_porter:purge` for automatic cleanup
29
33
  - **Declarative Target DSL** -- One class per import type, zero boilerplate ([docs](docs/TARGETS.md))
30
34
 
31
35
  ## Requirements
@@ -33,7 +37,6 @@ Supports CSV, JSON, XLSX, and API sources with a declarative DSL for defining im
33
37
  - Ruby >= 3.2
34
38
  - Rails >= 7.0
35
39
  - ActiveStorage (for file uploads)
36
- - ActionCable (optional, for real-time progress)
37
40
 
38
41
  ## Installation
39
42
 
@@ -54,7 +57,7 @@ The generator creates:
54
57
  Generate a target:
55
58
 
56
59
  ```bash
57
- bin/rails generate data_porter:target Product name:string:required price:integer sku:string
60
+ bin/rails generate data_porter:target Product name:string:required price:integer sku:string --sources csv xlsx
58
61
  ```
59
62
 
60
63
  Implement `persist` in `app/importers/product_target.rb`:
@@ -110,6 +113,7 @@ pending -> parsing -> previewing -> importing -> completed
110
113
  | [Targets](docs/TARGETS.md) | DSL reference, columns, hooks, generator |
111
114
  | [Sources](docs/SOURCES.md) | CSV, JSON, XLSX, API setup and examples |
112
115
  | [Column Mapping](docs/MAPPING.md) | Interactive mapping, templates, priority order |
116
+ | [Roadmap](docs/ROADMAP.md) | v1.0 plan and progress |
113
117
 
114
118
  ## Routes
115
119
 
@@ -118,6 +122,8 @@ pending -> parsing -> previewing -> importing -> completed
118
122
  | GET | `/imports` | List imports |
119
123
  | POST | `/imports` | Create import |
120
124
  | GET | `/imports/:id` | Show import |
125
+ | DELETE | `/imports/:id` | Delete import |
126
+ | GET | `/imports/:id/status` | JSON progress polling |
121
127
  | PATCH | `/imports/:id/update_mapping` | Save column mapping |
122
128
  | POST | `/imports/:id/parse` | Parse source |
123
129
  | POST | `/imports/:id/confirm` | Run import |
@@ -131,7 +137,7 @@ pending -> parsing -> previewing -> importing -> completed
131
137
  git clone https://github.com/SerylLns/data_porter.git
132
138
  cd data_porter
133
139
  bin/setup
134
- bundle exec rspec # 280 specs
140
+ bundle exec rspec # 354 specs
135
141
  bundle exec rubocop # 0 offenses
136
142
  ```
137
143
 
@@ -0,0 +1,126 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["targetSelect", "sourceSelect", "fileField", "fileInput", "dropzone", "fileName", "modal", "paramsContainer"]
5
+ static values = { sources: Object, params: Object }
6
+
7
+ connect() {
8
+ this.filterSources()
9
+ this.renderParams()
10
+ }
11
+
12
+ filterSources() {
13
+ if (!this.hasSourceSelectTarget || !this.hasTargetSelectTarget) return
14
+ var allowed = this.sourcesValue[this.targetSelectTarget.value]
15
+ var options = this.sourceSelectTarget.options
16
+ for (var i = 1; i < options.length; i++) {
17
+ options[i].style.display = allowed && allowed.indexOf(options[i].value) === -1 ? "none" : ""
18
+ }
19
+ if (allowed && this.sourceSelectTarget.selectedIndex > 0 && allowed.indexOf(this.sourceSelectTarget.value) === -1) {
20
+ this.sourceSelectTarget.selectedIndex = 0
21
+ this.fileFieldTarget.style.display = ""
22
+ }
23
+ this.renderParams()
24
+ }
25
+
26
+ renderParams() {
27
+ if (!this.hasParamsContainerTarget || !this.hasTargetSelectTarget) return
28
+ this.paramsContainerTarget.innerHTML = ""
29
+ var defs = this.paramsValue[this.targetSelectTarget.value] || []
30
+ var self = this
31
+ defs.forEach(function(p) { self.paramsContainerTarget.appendChild(self.buildParamField(p)) })
32
+ }
33
+
34
+ buildParamField(p) {
35
+ var div = document.createElement("div")
36
+ div.className = "dp-field"
37
+ div.appendChild(this.buildLabel(p))
38
+ div.appendChild(this.buildInput(p))
39
+ return div
40
+ }
41
+
42
+ buildLabel(p) {
43
+ var label = document.createElement("label")
44
+ label.className = "dp-label"
45
+ label.textContent = p.label + (p.required ? " *" : "")
46
+ return label
47
+ }
48
+
49
+ buildInput(p) {
50
+ if (p.type === "select" && p.collection) return this.buildSelect(p)
51
+ return this.buildTextField(p)
52
+ }
53
+
54
+ buildSelect(p) {
55
+ var select = document.createElement("select")
56
+ select.className = "dp-select"
57
+ select.name = "data_import[config][import_params][" + p.name + "]"
58
+ if (p.required) select.required = true
59
+ var blank = document.createElement("option")
60
+ blank.value = ""
61
+ blank.textContent = "Select..."
62
+ select.appendChild(blank)
63
+ p.collection.forEach(function(opt) {
64
+ var o = document.createElement("option")
65
+ o.textContent = opt[0]
66
+ o.value = opt[1]
67
+ if (p["default"] && String(opt[1]) === String(p["default"])) o.selected = true
68
+ select.appendChild(o)
69
+ })
70
+ return select
71
+ }
72
+
73
+ buildTextField(p) {
74
+ var input = document.createElement("input")
75
+ input.className = "dp-input"
76
+ input.type = p.type === "number" ? "number" : (p.type === "hidden" ? "hidden" : "text")
77
+ input.name = "data_import[config][import_params][" + p.name + "]"
78
+ if (p["default"]) input.value = p["default"]
79
+ if (p.required) input.required = true
80
+ return input
81
+ }
82
+
83
+ toggleFileField() {
84
+ this.fileFieldTarget.style.display = this.sourceSelectTarget.value === "api" ? "none" : ""
85
+ }
86
+
87
+ handleFile() {
88
+ if (this.fileInputTarget.files.length > 0) {
89
+ this.fileNameTarget.textContent = this.fileInputTarget.files[0].name
90
+ this.fileNameTarget.style.display = ""
91
+ this.dropzoneTarget.classList.add("dp-dropzone--has-file")
92
+ }
93
+ }
94
+
95
+ dragover(event) {
96
+ event.preventDefault()
97
+ this.dropzoneTarget.classList.add("dp-dropzone--dragover")
98
+ }
99
+
100
+ dragleave() {
101
+ this.dropzoneTarget.classList.remove("dp-dropzone--dragover")
102
+ }
103
+
104
+ drop(event) {
105
+ event.preventDefault()
106
+ this.dropzoneTarget.classList.remove("dp-dropzone--dragover")
107
+ if (event.dataTransfer.files.length > 0) {
108
+ this.fileInputTarget.files = event.dataTransfer.files
109
+ this.fileInputTarget.dispatchEvent(new Event("change"))
110
+ }
111
+ }
112
+
113
+ closeModal(event) {
114
+ if (event.key === "Escape") {
115
+ this.modalTarget.classList.remove("dp-modal--open")
116
+ }
117
+ }
118
+
119
+ openModal() {
120
+ this.modalTarget.classList.add("dp-modal--open")
121
+ }
122
+
123
+ closeModalClick() {
124
+ this.modalTarget.classList.remove("dp-modal--open")
125
+ }
126
+ }
@@ -49,3 +49,48 @@
49
49
  align-items: center;
50
50
  gap: 0.5rem;
51
51
  }
52
+
53
+ .dp-pagination {
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ gap: 1rem;
58
+ margin: 1.5rem 0;
59
+ }
60
+
61
+ .dp-pagination__btn {
62
+ display: inline-flex;
63
+ align-items: center;
64
+ padding: 0.5rem 1rem;
65
+ font-size: 0.875rem;
66
+ font-weight: 500;
67
+ color: var(--dp-primary);
68
+ background: white;
69
+ border: 1px solid var(--dp-gray-300);
70
+ border-radius: var(--dp-radius-md);
71
+ text-decoration: none;
72
+ cursor: pointer;
73
+ transition: all 0.15s ease;
74
+ }
75
+
76
+ .dp-pagination__btn:hover {
77
+ background: var(--dp-gray-50);
78
+ border-color: var(--dp-primary);
79
+ }
80
+
81
+ .dp-pagination__btn--disabled {
82
+ color: var(--dp-gray-400);
83
+ cursor: default;
84
+ pointer-events: none;
85
+ }
86
+
87
+ .dp-pagination__btn--disabled:hover {
88
+ background: white;
89
+ border-color: var(--dp-gray-300);
90
+ }
91
+
92
+ .dp-pagination__info {
93
+ font-size: 0.875rem;
94
+ color: var(--dp-gray-500);
95
+ font-weight: 500;
96
+ }
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Concerns
5
+ module ImportValidation
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def valid_source_for_target?
11
+ target = DataPorter::Registry.find(@import.target_key)
12
+ allowed = target._sources || DataPorter.configuration.enabled_sources
13
+ return true if allowed.map(&:to_s).include?(@import.source_type.to_s)
14
+
15
+ @import.errors.add(:source_type, "#{@import.source_type} is not available for this target")
16
+ false
17
+ end
18
+
19
+ def valid_file_presence?
20
+ return true unless %w[csv json xlsx].include?(@import.source_type)
21
+ return true if @import.file.attached?
22
+
23
+ @import.errors.add(:file, "must be attached for #{@import.source_type.upcase} imports")
24
+ false
25
+ end
26
+
27
+ def valid_import_params?
28
+ missing = missing_required_params
29
+ return true if missing.empty?
30
+
31
+ missing.each { |p| @import.errors.add(:base, "#{p.label} is required") }
32
+ false
33
+ end
34
+
35
+ def missing_required_params
36
+ target = DataPorter::Registry.find(@import.target_key)
37
+ required = (target._params || []).select(&:required)
38
+ values = import_param_values
39
+ required.reject { |p| values[p.name.to_s].present? }
40
+ end
41
+
42
+ def import_param_values
43
+ (@import.config || {}).fetch("import_params", {})
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Concerns
5
+ module MappingManagement
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def load_mapping_data
11
+ target = @import.target_class
12
+ columns = target._columns || []
13
+ @file_headers = @import.config["file_headers"] || []
14
+ @target_columns = columns.map { |c| [c.label, c.name.to_s, c.required] }
15
+ @default_mapping = (target._csv_mappings || {}).transform_values(&:to_s)
16
+ @templates = load_templates
17
+ end
18
+
19
+ def load_templates
20
+ return [] unless defined?(DataPorter::MappingTemplate)
21
+
22
+ DataPorter::MappingTemplate.for_target(@import.target_key)
23
+ end
24
+
25
+ def save_column_mapping
26
+ mapping = params.require(:column_mapping).permit!.to_h
27
+ merged = (@import.config || {}).merge("column_mapping" => mapping)
28
+ @import.update!(config: merged, status: :pending)
29
+ end
30
+
31
+ def save_template_if_requested
32
+ return unless params[:save_template] == "1"
33
+ return unless defined?(DataPorter::MappingTemplate)
34
+
35
+ mapping = params.require(:column_mapping).permit!.to_h
36
+ DataPorter::MappingTemplate.find_or_initialize_by(
37
+ target_key: @import.target_key,
38
+ name: params[:template_name].presence || "Default"
39
+ ).update!(mapping: mapping)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Concerns
5
+ module RecordPagination
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def paginate_records
11
+ per_page = 50
12
+ @total_pages = (@records.size.to_f / per_page).ceil
13
+ @total_pages = 1 if @total_pages.zero?
14
+ @page = (params[:page] || 1).to_i.clamp(1, @total_pages)
15
+ @records = @records.slice((@page - 1) * per_page, per_page) || []
16
+ end
17
+ end
18
+ end
19
+ end
@@ -2,6 +2,10 @@
2
2
 
3
3
  module DataPorter
4
4
  class ImportsController < DataPorter.configuration.parent_controller.constantize
5
+ include Concerns::ImportValidation
6
+ include Concerns::MappingManagement
7
+ include Concerns::RecordPagination
8
+
5
9
  layout "data_porter/application"
6
10
 
7
11
  before_action :set_import, only: %i[show parse confirm cancel dry_run update_mapping status destroy]
@@ -18,7 +22,7 @@ module DataPorter
18
22
  def create
19
23
  build_import
20
24
 
21
- if valid_source_for_target? && valid_file_presence? && @import.save
25
+ if valid_source_for_target? && valid_file_presence? && valid_import_params? && @import.save
22
26
  enqueue_after_create
23
27
  redirect_to import_path(@import)
24
28
  else
@@ -30,6 +34,7 @@ module DataPorter
30
34
  @target = @import.target_class
31
35
  @records = @import.records
32
36
  @grouped = @records.group_by(&:status)
37
+ paginate_records
33
38
  load_mapping_data if @import.mapping?
34
39
  end
35
40
 
@@ -91,24 +96,17 @@ module DataPorter
91
96
  end
92
97
 
93
98
  def import_params
94
- params.require(:data_import).permit(:target_key, :source_type, :file, config: {})
95
- end
96
-
97
- def valid_source_for_target?
98
- target = DataPorter::Registry.find(@import.target_key)
99
- allowed = target._sources || DataPorter.configuration.enabled_sources
100
- return true if allowed.map(&:to_s).include?(@import.source_type.to_s)
101
-
102
- @import.errors.add(:source_type, "#{@import.source_type} is not available for this target")
103
- false
99
+ permitted = params.require(:data_import).permit(:target_key, :source_type, :file, config: {})
100
+ merge_import_params(permitted)
104
101
  end
105
102
 
106
- def valid_file_presence?
107
- return true unless %w[csv json xlsx].include?(@import.source_type)
108
- return true if @import.file.attached?
103
+ def merge_import_params(permitted)
104
+ nested = params.dig(:data_import, :config, :import_params)
105
+ return permitted unless nested
109
106
 
110
- @import.errors.add(:file, "must be attached for #{@import.source_type.upcase} imports")
111
- false
107
+ config = permitted[:config] || {}
108
+ config["import_params"] = nested.permit!.to_h
109
+ permitted.merge(config: config)
112
110
  end
113
111
 
114
112
  def enqueue_after_create
@@ -118,37 +116,5 @@ module DataPorter
118
116
  DataPorter::ParseJob.perform_later(@import.id)
119
117
  end
120
118
  end
121
-
122
- def load_mapping_data
123
- target = @import.target_class
124
- columns = target._columns || []
125
- @file_headers = @import.config["file_headers"] || []
126
- @target_columns = columns.map { |c| [c.label, c.name.to_s, c.required] }
127
- @default_mapping = (target._csv_mappings || {}).transform_values(&:to_s)
128
- @templates = load_templates
129
- end
130
-
131
- def load_templates
132
- return [] unless defined?(DataPorter::MappingTemplate)
133
-
134
- DataPorter::MappingTemplate.for_target(@import.target_key)
135
- end
136
-
137
- def save_column_mapping
138
- mapping = params.require(:column_mapping).permit!.to_h
139
- merged = (@import.config || {}).merge("column_mapping" => mapping)
140
- @import.update!(config: merged, status: :pending)
141
- end
142
-
143
- def save_template_if_requested
144
- return unless params[:save_template] == "1"
145
- return unless defined?(DataPorter::MappingTemplate)
146
-
147
- mapping = params.require(:column_mapping).permit!.to_h
148
- DataPorter::MappingTemplate.find_or_initialize_by(
149
- target_key: @import.target_key,
150
- name: params[:template_name].presence || "Default"
151
- ).update!(mapping: mapping)
152
- end
153
119
  end
154
120
  end