data_porter 2.8.0 → 2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 31999033cc55a873fc7eb19520cd1e4a970accea6ee00f1c137b3f17c64f23d9
4
- data.tar.gz: f23524c9154af04b4c02ad6196a4fd036a5dc1e5c8f788c7807d1383e1462f9b
3
+ metadata.gz: 4d3f44f3aff296ae20b7ac78c00d4a9c6f12b2477837a7323528b26c75fd03d3
4
+ data.tar.gz: 305de334f31a96b2e9c26e74ba58974420957df85d5169ce2de51a017b1f3622
5
5
  SHA512:
6
- metadata.gz: 28faa4317369a57e1fca2999a54232a2404ae25034d90d2bdc42ad1b7dd4f090397f98e4c9643c0e5e5029c41d9dcaf37d3003068bf454b56b3fea1b837c7a98
7
- data.tar.gz: dada848b7498e58080cff0a1147a5b345eb996feb0c0eb7f715c611283767c782afa66899115da8436592aa9588a3cfb1541403e07ad2446b792cc8850a8cd5e
6
+ metadata.gz: 702e623e0983e44a9f4b149f47d8426d667cb553b12ed5eb781b99d8072944abe3bbf415e724d44ce9a086bf9b2dcf62efd031b83cfa5613b7ab5efe26604b43
7
+ data.tar.gz: 0a72e5ad9983833ffe451c076bafa07d1f433460b3b6020b6e9dcac0a20f263dbd73c0f796dfae3b52d08caeae11c531af510b70fd35880522da0ccda1a12e0a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.9.0] - 2026-03-11
11
+
12
+ ### Added
13
+
14
+ - **`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
15
+ - **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
16
+
17
+ ### Fixed
18
+
19
+ - **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
20
+ - **Rack 3 params compatibility** -- `permitted_column_mapping` in `MappingManagement` now handles `Rack::QueryParser::Params` via duck-typing instead of `params.require().permit!`
21
+ - **Trailing blank headers** -- `fallback_headers` strips trailing empty columns from CSV/XLSX header rows that caused phantom mapping slots in the UI
22
+ - **Empty data rows** -- CSV and XLSX `fetch` methods now filter out rows where all values are blank, preventing hundreds of false "incomplete" records
23
+ - **Turbo form compatibility** -- Mapping form renders with `data-turbo="false"` to prevent Turbo Drive from intercepting multipart form submissions in host apps
24
+ - **POST route aliases** -- `update_mapping` and `update_record` now accept both POST and PATCH to work around Turbo/UJS method override issues
25
+
10
26
  ## [2.8.0] - 2026-03-08
11
27
 
12
28
  ### 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
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
@@ -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)
@@ -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)
@@ -46,10 +46,17 @@ module DataPorter
46
46
  row.to_h.transform_keys { |k| k.parameterize(separator: "_").to_sym }
47
47
  end
48
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
53
+ end
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
@@ -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)
@@ -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.0"
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seryl Lounis