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 +4 -4
- data/CHANGELOG.md +16 -0
- data/app/assets/javascripts/data_porter/inline_edit_controller.js +16 -6
- data/app/assets/stylesheets/data_porter/inline_edit.css +8 -0
- data/app/assets/stylesheets/data_porter/table.css +10 -0
- data/app/controllers/data_porter/concerns/mapping_management.rb +3 -1
- data/config/routes.rb +2 -0
- data/lib/data_porter/components/mapping/form.rb +1 -0
- data/lib/data_porter/components/preview/table.rb +3 -1
- data/lib/data_porter/orchestrator/dry_runner.rb +7 -2
- data/lib/data_porter/sources/base.rb +9 -2
- data/lib/data_porter/sources/csv.rb +14 -5
- data/lib/data_porter/sources/xlsx.rb +16 -5
- data/lib/data_porter/target.rb +5 -1
- data/lib/data_porter/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4d3f44f3aff296ae20b7ac78c00d4a9c6f12b2477837a7323528b26c75fd03d3
|
|
4
|
+
data.tar.gz: 305de334f31a96b2e9c26e74ba58974420957df85d5169ce2de51a017b1f3622
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
17
|
-
input
|
|
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
|
-
|
|
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
|
|
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
|
@@ -62,7 +62,9 @@ module DataPorter
|
|
|
62
62
|
|
|
63
63
|
def render_cell(record, col)
|
|
64
64
|
value = cell_value(record, col)
|
|
65
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
56
|
+
stripped = raw_headers.reverse.drop_while(&:blank?).reverse
|
|
57
|
+
return stripped if stripped.any?(&:present?)
|
|
51
58
|
|
|
52
|
-
|
|
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
|
-
|
|
17
|
-
raw = ::CSV.parse_line(
|
|
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(
|
|
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
|
-
|
|
71
|
-
SEPARATORS.max_by { |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
|
-
|
|
17
|
-
raw =
|
|
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
|
-
|
|
39
|
+
offset = header_row_index
|
|
40
|
+
return [] if rows.size <= offset + 1
|
|
40
41
|
|
|
41
|
-
headers = rows
|
|
42
|
-
rows
|
|
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)
|
data/lib/data_porter/target.rb
CHANGED
|
@@ -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(&)
|
data/lib/data_porter/version.rb
CHANGED