data_porter 2.8.0 → 2.9.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 31999033cc55a873fc7eb19520cd1e4a970accea6ee00f1c137b3f17c64f23d9
4
- data.tar.gz: f23524c9154af04b4c02ad6196a4fd036a5dc1e5c8f788c7807d1383e1462f9b
3
+ metadata.gz: a04398e443f704b747eff3f3b585e05f839cabd2f92ecf7871874811a079f15c
4
+ data.tar.gz: ade55b5a550d628acaf5f8d0e8cc0652fabcb2c7505b59bd14367e7ab56557cf
5
5
  SHA512:
6
- metadata.gz: 28faa4317369a57e1fca2999a54232a2404ae25034d90d2bdc42ad1b7dd4f090397f98e4c9643c0e5e5029c41d9dcaf37d3003068bf454b56b3fea1b837c7a98
7
- data.tar.gz: dada848b7498e58080cff0a1147a5b345eb996feb0c0eb7f715c611283767c782afa66899115da8436592aa9588a3cfb1541403e07ad2446b792cc8850a8cd5e
6
+ metadata.gz: 9647861c80be730b057a74854c91569bbc2e6e7d922cff5bffd28d7d4268f2a55e83bc08247b4ea93b2c9991e1ac2da1da3f4e71970b71225d9c08e5440ca7d6
7
+ data.tar.gz: b96e83067441054e1a0140c7b826043984d7fcc0d6c7b7f7ee7fe2a09d416365670a7bce7ed1eeb703d6949ee9dd24d7c791b06978d12d8c52929fa17b4553c6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.9.1] - 2026-03-11
11
+
12
+ ### Changed
13
+
14
+ - **Record data keys normalized to strings** -- `record.data` now uses `HashWithIndifferentAccess`, so both `record.data["key"]` and `record.data[:key]` work consistently. Sources, RecordBuilder, ColumnTransformer, and RecordValidator all produce string keys. No breaking change for existing Targets
15
+ - **Delete on mapping and preview** -- Imports in `mapping` and `previewing` status now show a Delete button with confirmation. Previously only `completed` and `failed` imports could be deleted. The preview Cancel button (which set status to `failed` without deleting) is replaced by Delete
16
+
17
+ ## [2.9.0] - 2026-03-11
18
+
19
+ ### Added
20
+
21
+ - **`header_row` DSL** -- Skip leading description/metadata rows in CSV and XLSX files. Declare `header_row 2` in Target DSL to treat the 3rd row (0-indexed) as headers. Also configurable at runtime via `config["header_row"]` on individual imports
22
+ - **Long text cell handling** -- Preview table cells with long content are truncated with ellipsis and show full text on hover via `title` tooltip. Inline edit switches to `<textarea>` for values over 50 characters with auto-height resize. Ctrl+Enter to save in textarea mode
23
+
24
+ ### Fixed
25
+
26
+ - **Dry run persistence** -- Dry run now wraps `persist` in a rolled-back `ActiveRecord::Base.transaction` instead of calling persist without rollback, which was creating real records
27
+ - **Rack 3 params compatibility** -- `permitted_column_mapping` in `MappingManagement` now handles `Rack::QueryParser::Params` via duck-typing instead of `params.require().permit!`
28
+ - **Trailing blank headers** -- `fallback_headers` strips trailing empty columns from CSV/XLSX header rows that caused phantom mapping slots in the UI
29
+ - **Empty data rows** -- CSV and XLSX `fetch` methods now filter out rows where all values are blank, preventing hundreds of false "incomplete" records
30
+ - **Turbo form compatibility** -- Mapping form renders with `data-turbo="false"` to prevent Turbo Drive from intercepting multipart form submissions in host apps
31
+ - **POST route aliases** -- `update_mapping` and `update_record` now accept both POST and PATCH to work around Turbo/UJS method override issues
32
+
10
33
  ## [2.8.0] - 2026-03-08
11
34
 
12
35
  ### Added
@@ -13,8 +13,9 @@ export default class extends Controller {
13
13
  cell.textContent = ""
14
14
  cell.classList.add("dp-cell--editing")
15
15
 
16
- var input = document.createElement("input")
17
- input.type = "text"
16
+ var isLong = value.length > 50
17
+ var input = document.createElement(isLong ? "textarea" : "input")
18
+ if (!isLong) input.type = "text"
18
19
  input.className = "dp-inline-input"
19
20
  input.value = value
20
21
  input.dataset.lineNumber = cell.dataset.lineNumber
@@ -22,18 +23,22 @@ export default class extends Controller {
22
23
 
23
24
  var self = this
24
25
  input._blurHandler = function(e) { self.save(e) }
25
- input._keyHandler = function(e) { self.handleKey(e) }
26
+ input._keyHandler = function(e) { self.handleKey(e, isLong) }
26
27
  input.addEventListener("blur", input._blurHandler)
27
28
  input.addEventListener("keydown", input._keyHandler)
28
29
  cell.appendChild(input)
29
30
  this.autoSize(input)
30
- input.addEventListener("input", function() { self.autoSize(input) })
31
+ if (isLong) this.autoHeight(input)
32
+ input.addEventListener("input", function() {
33
+ self.autoSize(input)
34
+ if (isLong) self.autoHeight(input)
35
+ })
31
36
  input.focus()
32
37
  input.select()
33
38
  }
34
39
 
35
- handleKey(event) {
36
- if (event.key === "Enter") {
40
+ handleKey(event, isTextarea) {
41
+ if (event.key === "Enter" && (!isTextarea || event.ctrlKey || event.metaKey)) {
37
42
  event.preventDefault()
38
43
  event.target.blur()
39
44
  } else if (event.key === "Escape") {
@@ -176,6 +181,11 @@ export default class extends Controller {
176
181
  document.body.removeChild(span)
177
182
  }
178
183
 
184
+ autoHeight(textarea) {
185
+ textarea.style.height = "auto"
186
+ textarea.style.height = textarea.scrollHeight + "px"
187
+ }
188
+
179
189
  moveToNext(currentCell, reverse) {
180
190
  var cells = Array.from(this.cellTargets)
181
191
  var index = cells.indexOf(currentCell)
@@ -52,6 +52,14 @@
52
52
  z-index: 10;
53
53
  }
54
54
 
55
+ textarea.dp-inline-input {
56
+ resize: none;
57
+ overflow: hidden;
58
+ min-height: 2.5rem;
59
+ white-space: pre-wrap;
60
+ word-break: break-word;
61
+ }
62
+
55
63
  .dp-inline-input:focus {
56
64
  box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15);
57
65
  }
@@ -30,6 +30,16 @@
30
30
  font-size: 0.875rem;
31
31
  color: var(--dp-gray-700);
32
32
  border-bottom: 1px solid var(--dp-gray-100);
33
+ max-width: 15rem;
34
+ overflow: hidden;
35
+ text-overflow: ellipsis;
36
+ white-space: nowrap;
37
+ }
38
+
39
+ .dp-table td.dp-cell--editing {
40
+ max-width: none;
41
+ overflow: visible;
42
+ white-space: normal;
33
43
  }
34
44
 
35
45
  .dp-table tbody tr:last-child td {
@@ -69,7 +69,9 @@ module DataPorter
69
69
  end
70
70
 
71
71
  def permitted_column_mapping
72
- raw = params.require(:column_mapping).permit!.to_h
72
+ raw = params[:column_mapping]
73
+ raw = raw.to_unsafe_h if raw.respond_to?(:to_unsafe_h)
74
+ raw = raw.to_h unless raw.is_a?(Hash)
73
75
  valid_names = valid_column_names
74
76
  raw.transform_values { |v| valid_names.include?(v) ? v : "" }
75
77
  end
@@ -42,6 +42,11 @@
42
42
  templates: @templates,
43
43
  default_mapping: @default_mapping
44
44
  ).call %>
45
+ <div class="dp-actions">
46
+ <%= button_to t("data_porter.imports.delete"), import_path(@import),
47
+ method: :delete, class: "dp-btn dp-btn--danger",
48
+ data: { turbo_confirm: t("data_porter.imports.delete_confirm") } %>
49
+ </div>
45
50
  <% end %>
46
51
 
47
52
  <% if @import.previewing? %>
@@ -71,8 +76,9 @@
71
76
  <%= button_to t("data_porter.imports.back_to_mapping"), back_to_mapping_import_path(@import),
72
77
  method: :post, class: "dp-btn dp-btn--secondary" %>
73
78
  <% end %>
74
- <%= button_to t("data_porter.imports.cancel"), cancel_import_path(@import),
75
- method: :post, class: "dp-btn dp-btn--danger" %>
79
+ <%= button_to t("data_porter.imports.delete"), import_path(@import),
80
+ method: :delete, class: "dp-btn dp-btn--danger",
81
+ data: { turbo_confirm: t("data_porter.imports.delete_confirm") } %>
76
82
  </div>
77
83
  <% end %>
78
84
 
data/config/routes.rb CHANGED
@@ -11,7 +11,9 @@ DataPorter::Engine.routes.draw do
11
11
  post :back_to_mapping
12
12
  post :dry_run
13
13
  post :resume
14
+ post :update_mapping
14
15
  patch :update_mapping
16
+ post :update_record
15
17
  patch :update_record
16
18
  get :status
17
19
  get :export_rejects
@@ -41,10 +41,8 @@ module DataPorter
41
41
  end
42
42
 
43
43
  def self.resolve_key(data, name)
44
- return name.to_s if data.key?(name.to_s)
45
- return name if data.key?(name)
46
-
47
- nil
44
+ key = name.to_s
45
+ key if data.key?(key)
48
46
  end
49
47
 
50
48
  def self.custom_transformers
@@ -22,6 +22,7 @@ module DataPorter
22
22
  action: @action_url,
23
23
  method: "post",
24
24
  class: "dp-mapping-form",
25
+ data_turbo: "false",
25
26
  data_controller: "data-porter--mapping",
26
27
  data_data_porter__mapping_required_columns_value: required_columns_json
27
28
  ) { render_form_body }
@@ -62,7 +62,9 @@ module DataPorter
62
62
 
63
63
  def render_cell(record, col)
64
64
  value = cell_value(record, col)
65
- td(**cell_attrs(record, col, value)) { value }
65
+ attrs = cell_attrs(record, col, value)
66
+ attrs[:title] = value if value.length > 30
67
+ td(**attrs) { value }
66
68
  end
67
69
 
68
70
  def render_errors_cell(record)
@@ -70,7 +72,7 @@ module DataPorter
70
72
  end
71
73
 
72
74
  def cell_value(record, col)
73
- (record.data[col.name.to_s] || record.data[col.name]).to_s
75
+ record.data[col.name.to_s].to_s
74
76
  end
75
77
 
76
78
  def row_attrs(record)
@@ -19,8 +19,13 @@ module DataPorter
19
19
  end
20
20
 
21
21
  def dry_run_record(record, context)
22
- @target.persist(record, context: context)
23
- record.dry_run_passed = true
22
+ ActiveRecord::Base.transaction do
23
+ @target.persist(record, context: context)
24
+ record.dry_run_passed = true
25
+ raise ActiveRecord::Rollback
26
+ end
27
+ rescue ActiveRecord::Rollback
28
+ nil
24
29
  rescue StandardError => e
25
30
  record.dry_run_passed = false
26
31
  record.add_error(e.message)
@@ -40,7 +40,7 @@ module DataPorter
40
40
 
41
41
  def extract_data(row, columns)
42
42
  columns.each_with_object({}) do |col, hash|
43
- hash[col.name] = row[col.name] || row[col.name.to_s]
43
+ hash[col.name.to_s] = row[col.name.to_s] || row[col.name]
44
44
  end
45
45
  end
46
46
  end
@@ -24,9 +24,10 @@ module DataPorter
24
24
  def normalize_keys(record)
25
25
  @columns.each do |col|
26
26
  string_key = col.name.to_s
27
- next unless record.data.key?(string_key) && !record.data.key?(col.name)
27
+ next if record.data.key?(string_key)
28
+ next unless record.data.key?(col.name)
28
29
 
29
- record.data[col.name] = record.data.delete(string_key)
30
+ record.data[string_key] = record.data.delete(col.name)
30
31
  end
31
32
  end
32
33
  end
@@ -32,10 +32,7 @@ module DataPorter
32
32
  record.data[key] = value
33
33
  end
34
34
 
35
- def resolve_key(data, column)
36
- return column.to_sym if data.key?(column.to_sym)
37
- return column.to_s if data.key?(column.to_s)
38
-
35
+ def resolve_key(_data, column)
39
36
  column.to_s
40
37
  end
41
38
 
@@ -8,7 +8,7 @@ module DataPorter
8
8
 
9
9
  def validate(record)
10
10
  @columns.each do |col|
11
- value = record.data[col.name]
11
+ value = record.data[col.name.to_s]
12
12
  validate_required(record, col, value)
13
13
  validate_type(record, col, value)
14
14
  end
@@ -13,7 +13,7 @@ module DataPorter
13
13
  records = extract_records(parsed, api)
14
14
 
15
15
  Array(records).map do |hash|
16
- hash.transform_keys { |k| k.parameterize(separator: "_").to_sym }
16
+ hash.transform_keys { |k| k.parameterize(separator: "_") }
17
17
  end
18
18
  end
19
19
 
@@ -32,24 +32,31 @@ module DataPorter
32
32
 
33
33
  def user_map(row)
34
34
  user_mapping.each_with_object({}) do |(header, column), hash|
35
- hash[column.to_sym] = row[header]
35
+ hash[column.to_s] = row[header]
36
36
  end
37
37
  end
38
38
 
39
39
  def explicit_map(row, mappings)
40
40
  mappings.each_with_object({}) do |(header, column), hash|
41
- hash[column] = row[header]
41
+ hash[column.to_s] = row[header]
42
42
  end
43
43
  end
44
44
 
45
45
  def auto_map(row)
46
- row.to_h.transform_keys { |k| k.parameterize(separator: "_").to_sym }
46
+ row.to_h.transform_keys { |k| k.parameterize(separator: "_") }
47
+ end
48
+
49
+ def header_row_index
50
+ config = @data_import.config
51
+ from_config = config["header_row"] if config.is_a?(Hash)
52
+ from_config&.to_i || @target_class._header_row || 0
47
53
  end
48
54
 
49
55
  def fallback_headers(raw_headers)
50
- return raw_headers if raw_headers.any?(&:present?)
56
+ stripped = raw_headers.reverse.drop_while(&:blank?).reverse
57
+ return stripped if stripped.any?(&:present?)
51
58
 
52
- raw_headers.each_with_index.map { |_, i| "col_#{i + 1}" }
59
+ stripped.each_with_index.map { |_, i| "col_#{i + 1}" }
53
60
  end
54
61
  end
55
62
  end
@@ -13,14 +13,16 @@ module DataPorter
13
13
  end
14
14
 
15
15
  def headers
16
- first_line = csv_content.lines.first
17
- raw = ::CSV.parse_line(first_line, **extra_options).map(&:to_s)
16
+ line = csv_content.lines[header_row_index]
17
+ raw = ::CSV.parse_line(line, **extra_options).map(&:to_s)
18
18
  fallback_headers(raw)
19
19
  end
20
20
 
21
21
  def fetch
22
22
  rows = []
23
- ::CSV.parse(csv_content, **csv_options) do |row|
23
+ ::CSV.parse(effective_csv_content, **csv_options) do |row|
24
+ next if row.to_h.values.all? { |v| v.to_s.strip.empty? }
25
+
24
26
  rows << apply_csv_mapping(row)
25
27
  end
26
28
  rows
@@ -32,6 +34,13 @@ module DataPorter
32
34
  @csv_content ||= ensure_utf8(@content || download_file)
33
35
  end
34
36
 
37
+ def effective_csv_content
38
+ offset = header_row_index
39
+ return csv_content if offset.zero?
40
+
41
+ csv_content.lines.drop(offset).join
42
+ end
43
+
35
44
  def download_file
36
45
  @data_import.file.download
37
46
  end
@@ -67,8 +76,8 @@ module DataPorter
67
76
  end
68
77
 
69
78
  def detect_separator
70
- first_line = csv_content.lines.first.to_s
71
- SEPARATORS.max_by { |sep| first_line.count(sep) }
79
+ header_line = csv_content.lines[header_row_index].to_s
80
+ SEPARATORS.max_by { |sep| header_line.count(sep) }
72
81
  end
73
82
  end
74
83
  end
@@ -15,7 +15,7 @@ module DataPorter
15
15
  records = extract_records(parsed)
16
16
 
17
17
  Array(records).map do |hash|
18
- hash.transform_keys { |k| k.parameterize(separator: "_").to_sym }
18
+ hash.transform_keys { |k| k.parameterize(separator: "_") }
19
19
  end
20
20
  end
21
21
 
@@ -13,8 +13,8 @@ module DataPorter
13
13
 
14
14
  def headers
15
15
  sheet = target_sheet
16
- first_row = sheet.simple_rows.first
17
- raw = first_row&.values&.map(&:to_s) || []
16
+ header = sheet.simple_rows.drop(header_row_index).first
17
+ raw = header&.values&.map(&:to_s) || []
18
18
  fallback_headers(raw)
19
19
  ensure
20
20
  cleanup
@@ -36,10 +36,21 @@ module DataPorter
36
36
 
37
37
  def parse_sheet(sheet)
38
38
  rows = sheet.simple_rows.to_a
39
- return [] if rows.size <= 1
39
+ offset = header_row_index
40
+ return [] if rows.size <= offset + 1
40
41
 
41
- headers = rows.first.values.map(&:to_s)
42
- rows.drop(1).map { |row| build_row(headers, row) }
42
+ headers = extract_headers(rows, offset)
43
+ extract_data_rows(rows, offset, headers)
44
+ end
45
+
46
+ def extract_headers(rows, offset)
47
+ rows[offset].values.map(&:to_s)
48
+ end
49
+
50
+ def extract_data_rows(rows, offset, headers)
51
+ rows.drop(offset + 1)
52
+ .reject { |row| row.values.all? { |v| v.to_s.strip.empty? } }
53
+ .map { |row| build_row(headers, row) }
43
54
  end
44
55
 
45
56
  def build_row(headers, row)
@@ -10,8 +10,12 @@ module DataPorter
10
10
 
11
11
  attribute :line_number, :integer
12
12
  attribute :status, :string, default: "pending"
13
- attribute :data, default: -> { {} }
13
+ attribute :data, default: -> { HashWithIndifferentAccess.new }
14
14
  attribute :errors_list, Error.to_array_type, default: -> { [] }
15
+
16
+ def data=(value)
17
+ super(value.is_a?(Hash) ? value.with_indifferent_access : value)
18
+ end
15
19
  attribute :warnings, Error.to_array_type, default: -> { [] }
16
20
  attribute :target_id, :integer
17
21
  attribute :dry_run_passed, :boolean, default: false
@@ -29,7 +33,7 @@ module DataPorter
29
33
  end
30
34
 
31
35
  def attributes
32
- data.symbolize_keys.compact
36
+ data.stringify_keys.compact.with_indifferent_access
33
37
  end
34
38
 
35
39
  def determine_status!
@@ -11,7 +11,7 @@ module DataPorter
11
11
  attr_reader :_label, :_model_name, :_icon, :_sources,
12
12
  :_columns, :_csv_mappings, :_dedup_keys, :_json_root,
13
13
  :_api_config, :_dry_run_enabled, :_params, :_webhooks,
14
- :_bulk_config
14
+ :_bulk_config, :_header_row
15
15
 
16
16
  def label(value)
17
17
  @_label = value
@@ -65,6 +65,10 @@ module DataPorter
65
65
  @_dry_run_enabled = true
66
66
  end
67
67
 
68
+ def header_row(value)
69
+ @_header_row = value.to_i
70
+ end
71
+
68
72
  def params(&)
69
73
  @_params = []
70
74
  instance_eval(&)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DataPorter
4
- VERSION = "2.8.0"
4
+ VERSION = "2.9.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: data_porter
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.8.0
4
+ version: 2.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seryl Lounis