data_porter 2.9.0 → 2.10.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: 4d3f44f3aff296ae20b7ac78c00d4a9c6f12b2477837a7323528b26c75fd03d3
4
- data.tar.gz: 305de334f31a96b2e9c26e74ba58974420957df85d5169ce2de51a017b1f3622
3
+ metadata.gz: fb7f4978449eef549b221a5bb8c2e297ca19ce4a91161d80a18f02dce1e68555
4
+ data.tar.gz: 995a508847ef2ecf97796096a374e142026074662d349c9cb912272a4a6c0b0c
5
5
  SHA512:
6
- metadata.gz: 702e623e0983e44a9f4b149f47d8426d667cb553b12ed5eb781b99d8072944abe3bbf415e724d44ce9a086bf9b2dcf62efd031b83cfa5613b7ab5efe26604b43
7
- data.tar.gz: 0a72e5ad9983833ffe451c076bafa07d1f433460b3b6020b6e9dcac0a20f263dbd73c0f796dfae3b52d08caeae11c531af510b70fd35880522da0ccda1a12e0a
6
+ metadata.gz: 3660607affb95f6f4325f3175adf413c472a7fec30961919cc3138a7cdb5bea4ea94fe29ee894425ff596663ded515e9ba7d6d33a3c15ea97ccfccce4bbef8a1
7
+ data.tar.gz: 5691769ca62b86555a372ef942a0049b3373a7c2ed6585990189cc266a8047071ae13cd613189bb0bc6d07b7e939535cd7d82331bce7a1ca850ebb8da9a050b5
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.10.0] - 2026-10-19
11
+
12
+ ### Added
13
+
14
+ - **Header row field in import form** -- The modal and standalone `new` import forms show a "Header row" input for CSV and XLSX sources. Users enter a 1-based row number, stored as a 0-based `config["header_row"]`. The field is pre-filled from the Target's `header_row` DSL value, exposed by `Registry.available` as `header_row`
15
+
16
+ ### Fixed
17
+
18
+ - **Import params on inline edit** -- `RecordUpdater` now hydrates `import_params` on the target before revalidating, so validations depending on them no longer fail on valid records (#3)
19
+ - **File extension validation** -- The uploaded file extension is cross-checked against the selected source type before saving, surfacing mismatches as a form error instead of crashing the parser (#4)
20
+
21
+ ## [2.9.1] - 2026-03-11
22
+
23
+ ### Changed
24
+
25
+ - **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
26
+ - **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
27
+
10
28
  ## [2.9.0] - 2026-03-11
11
29
 
12
30
  ### Added
@@ -1,12 +1,13 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
- static targets = ["targetSelect", "sourceSelect", "fileField", "fileInput", "dropzone", "fileName", "modal", "paramsContainer"]
5
- static values = { sources: Object, params: Object }
4
+ static targets = ["targetSelect", "sourceSelect", "fileField", "fileInput", "dropzone", "fileName", "modal", "paramsContainer", "headerRowField", "headerRowInput"]
5
+ static values = { sources: Object, params: Object, headerRows: Object }
6
6
 
7
7
  connect() {
8
8
  this.filterSources()
9
9
  this.renderParams()
10
+ this.toggleHeaderRow()
10
11
  }
11
12
 
12
13
  filterSources() {
@@ -21,6 +22,19 @@ export default class extends Controller {
21
22
  this.fileFieldTarget.style.display = ""
22
23
  }
23
24
  this.renderParams()
25
+ this.applyHeaderRowDefault()
26
+ this.toggleHeaderRow()
27
+ }
28
+
29
+ applyHeaderRowDefault() {
30
+ if (!this.hasHeaderRowInputTarget || !this.hasTargetSelectTarget) return
31
+ this.headerRowInputTarget.value = this.headerRowsValue[this.targetSelectTarget.value] || 1
32
+ }
33
+
34
+ toggleHeaderRow() {
35
+ if (!this.hasHeaderRowFieldTarget || !this.hasSourceSelectTarget) return
36
+ var visible = ["csv", "xlsx"].indexOf(this.sourceSelectTarget.value) !== -1
37
+ this.headerRowFieldTarget.style.display = visible ? "" : "none"
24
38
  }
25
39
 
26
40
  renderParams() {
@@ -83,6 +97,7 @@ export default class extends Controller {
83
97
 
84
98
  toggleFileField() {
85
99
  this.fileFieldTarget.style.display = this.sourceSelectTarget.value === "api" ? "none" : ""
100
+ this.toggleHeaderRow()
86
101
  }
87
102
 
88
103
  handleFile() {
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Concerns
5
+ module ImportParamsBuilder
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def import_params
11
+ permitted = params.require(:data_import).permit(:target_key, :source_type, :file, config: {})
12
+ permitted = merge_import_params(permitted)
13
+ merge_header_row(permitted)
14
+ end
15
+
16
+ def merge_import_params(permitted)
17
+ nested = params.dig(:data_import, :config, :import_params)
18
+ return permitted unless nested
19
+
20
+ config = permitted[:config]&.to_unsafe_h || {}
21
+ config["import_params"] = nested.permit(*allowed_param_keys).to_h
22
+ permitted.merge(config: config)
23
+ end
24
+
25
+ def merge_header_row(permitted)
26
+ raw = params.dig(:data_import, :header_row)
27
+ return permitted if raw.blank?
28
+
29
+ config = permitted[:config]&.to_unsafe_h || {}
30
+ config["header_row"] = [raw.to_i - 1, 0].max
31
+ permitted.merge(config: config)
32
+ end
33
+
34
+ def allowed_param_keys
35
+ target_key = params.dig(:data_import, :target_key)
36
+ return [] unless target_key
37
+
38
+ target = DataPorter::Registry.find(target_key)
39
+ (target._params || []).map { |p| p.name.to_s }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -11,6 +11,12 @@ module DataPorter
11
11
  "xlsx" => %w[application/vnd.openxmlformats-officedocument.spreadsheetml.sheet]
12
12
  }.freeze
13
13
 
14
+ EXTENSIONS_BY_SOURCE = {
15
+ "csv" => %w[.csv],
16
+ "json" => %w[.json],
17
+ "xlsx" => %w[.xlsx]
18
+ }.freeze
19
+
14
20
  private
15
21
 
16
22
  def valid_source_for_target?
@@ -59,6 +65,26 @@ module DataPorter
59
65
  false
60
66
  end
61
67
 
68
+ def valid_file_extension_for_source?
69
+ return true unless @import.file.attached?
70
+
71
+ allowed = EXTENSIONS_BY_SOURCE[@import.source_type]
72
+ return true unless allowed
73
+
74
+ actual = File.extname(@import.file.blob.filename.to_s).downcase
75
+ return true if allowed.include?(actual)
76
+
77
+ @import.errors.add(
78
+ :file,
79
+ I18n.t(
80
+ "data_porter.errors.file_extension_mismatch",
81
+ expected: allowed.join(", "),
82
+ actual: actual.presence || "none"
83
+ )
84
+ )
85
+ false
86
+ end
87
+
62
88
  def valid_file_content_type?
63
89
  return true unless @import.file.attached?
64
90
 
@@ -3,6 +3,7 @@
3
3
  module DataPorter
4
4
  class ImportsController < DataPorter.configuration.parent_controller.constantize
5
5
  include Concerns::ImportValidation
6
+ include Concerns::ImportParamsBuilder
6
7
  include Concerns::MappingManagement
7
8
  include Concerns::RecordPagination
8
9
  include Concerns::ScopeManagement
@@ -144,33 +145,12 @@ module DataPorter
144
145
  valid_source_for_target?,
145
146
  valid_file_presence?,
146
147
  valid_file_size?,
148
+ valid_file_extension_for_source?,
147
149
  valid_file_content_type?,
148
150
  valid_import_params?
149
151
  ].all?
150
152
  end
151
153
 
152
- def import_params
153
- permitted = params.require(:data_import).permit(:target_key, :source_type, :file, config: {})
154
- merge_import_params(permitted)
155
- end
156
-
157
- def merge_import_params(permitted)
158
- nested = params.dig(:data_import, :config, :import_params)
159
- return permitted unless nested
160
-
161
- config = permitted[:config]&.to_unsafe_h || {}
162
- config["import_params"] = nested.permit(*allowed_param_keys).to_h
163
- permitted.merge(config: config)
164
- end
165
-
166
- def allowed_param_keys
167
- target_key = params.dig(:data_import, :target_key)
168
- return [] unless target_key
169
-
170
- target = DataPorter::Registry.find(target_key)
171
- (target._params || []).map { |p| p.name.to_s }
172
- end
173
-
174
154
  def enqueue_after_create
175
155
  if @import.file_based?
176
156
  DataPorter::ExtractHeadersJob.perform_later(@import.id)
@@ -1,6 +1,7 @@
1
1
  <div class="data-porter" data-controller="data-porter--import-form"
2
2
  data-data-porter--import-form-sources-value="<%= @targets.map { |t| [t[:key], t[:sources]] }.to_h.to_json %>"
3
3
  data-data-porter--import-form-params-value="<%= @targets.map { |t| [t[:key], t[:params]] }.to_h.to_json %>"
4
+ data-data-porter--import-form-header-rows-value="<%= @targets.map { |t| [t[:key], t[:header_row]] }.to_h.to_json %>"
4
5
  data-action="keydown@document->data-porter--import-form#closeModal">
5
6
  <div class="dp-header">
6
7
  <h1 class="dp-title"><%= t("data_porter.imports.title") %></h1>
@@ -105,6 +106,13 @@
105
106
  </label>
106
107
  </div>
107
108
 
109
+ <div class="dp-field" data-data-porter--import-form-target="headerRowField">
110
+ <%= label_tag "dp-header-row-input-modal", t("data_porter.imports.form.header_row_label"), class: "dp-label" %>
111
+ <input type="number" name="data_import[header_row]" id="dp-header-row-input-modal" class="dp-input" value="1" min="1" step="1"
112
+ data-data-porter--import-form-target="headerRowInput" />
113
+ <small class="dp-hint"><%= t("data_porter.imports.form.header_row_hint") %></small>
114
+ </div>
115
+
108
116
  <div class="dp-modal__footer">
109
117
  <%= f.submit t("data_porter.imports.start_import"), class: "dp-btn dp-btn--primary" %>
110
118
  <button type="button" class="dp-btn dp-btn--secondary" data-action="data-porter--import-form#closeModalClick"><%= t("data_porter.imports.cancel") %></button>
@@ -21,7 +21,8 @@
21
21
  class: "dp-select",
22
22
  data: {
23
23
  sources: @targets.map { |t| [t[:key], t[:sources]] }.to_h.to_json,
24
- params: @targets.map { |t| [t[:key], t[:params]] }.to_h.to_json
24
+ params: @targets.map { |t| [t[:key], t[:params]] }.to_h.to_json,
25
+ header_rows: @targets.map { |t| [t[:key], t[:header_row]] }.to_h.to_json
25
26
  } %>
26
27
  </div>
27
28
 
@@ -49,6 +50,12 @@
49
50
  </label>
50
51
  </div>
51
52
 
53
+ <div id="dp-header-row-field-new" class="dp-field">
54
+ <%= label_tag "dp-header-row-input-new", t("data_porter.imports.form.header_row_label"), class: "dp-label" %>
55
+ <input type="number" name="data_import[header_row]" id="dp-header-row-input-new" class="dp-input" value="1" min="1" step="1" />
56
+ <small class="dp-hint"><%= t("data_porter.imports.form.header_row_hint") %></small>
57
+ </div>
58
+
52
59
  <div class="dp-actions">
53
60
  <%= f.submit t("data_porter.imports.start_import"), class: "dp-btn dp-btn--primary" %>
54
61
  <%= link_to t("data_porter.imports.cancel"), imports_path, class: "dp-btn dp-btn--secondary" %>
@@ -64,8 +71,23 @@
64
71
  var fileInput = document.getElementById("dp-file-input-new");
65
72
  var dropzone = document.getElementById("dp-dropzone-new");
66
73
  var fileName = document.getElementById("dp-file-name-new");
74
+ var headerRowField = document.getElementById("dp-header-row-field-new");
67
75
  var paramsContainer = document.getElementById("dp-params-container");
68
76
  var paramsData = JSON.parse((targetSelect && targetSelect.dataset.params) || "{}");
77
+ var HEADER_ROW_SOURCES = ["csv", "xlsx"];
78
+
79
+ var headerRowInput = document.getElementById("dp-header-row-input-new");
80
+ var headerRowsData = JSON.parse((targetSelect && targetSelect.dataset.headerRows) || "{}");
81
+
82
+ function toggleHeaderRow() {
83
+ if (!headerRowField || !sourceSelect) return;
84
+ headerRowField.style.display = HEADER_ROW_SOURCES.indexOf(sourceSelect.value) === -1 ? "none" : "";
85
+ }
86
+
87
+ function applyHeaderRowDefault() {
88
+ if (!headerRowInput || !targetSelect) return;
89
+ headerRowInput.value = headerRowsData[targetSelect.value] || 1;
90
+ }
69
91
 
70
92
  function renderParams() {
71
93
  if (!paramsContainer || !targetSelect) return;
@@ -124,13 +146,16 @@
124
146
  targetSelect.addEventListener("change", function() {
125
147
  filterSources();
126
148
  renderParams();
149
+ applyHeaderRowDefault();
127
150
  });
128
151
  }
129
152
 
130
153
  if (sourceSelect) {
131
154
  sourceSelect.addEventListener("change", function() {
132
155
  fileField.style.display = this.value === "api" ? "none" : "";
156
+ toggleHeaderRow();
133
157
  });
158
+ toggleHeaderRow();
134
159
  }
135
160
 
136
161
  if (fileInput) {
@@ -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
 
@@ -43,6 +43,8 @@ en:
43
43
  dropzone_text: "Drop your file here or <strong>browse</strong>"
44
44
  dropzone_hint: "CSV, JSON, or XLSX files accepted"
45
45
  select_prompt: "Select..."
46
+ header_row_label: "Header row"
47
+ header_row_hint: "Row number (starting at 1) containing the column headers. Leave at 1 unless your file has description rows above the headers."
46
48
  mapping:
47
49
  load_template: "Load Template"
48
50
  save_as_template: "Save as template"
@@ -124,3 +126,4 @@ en:
124
126
  file_required: "must be attached for %{source} imports"
125
127
  file_too_large: "is too large (max %{max} MB)"
126
128
  invalid_content_type: "has an invalid content type (%{type})"
129
+ file_extension_mismatch: "expected a %{expected} file but got %{actual}"
@@ -43,6 +43,8 @@ fr:
43
43
  dropzone_text: "Déposez votre fichier ici ou <strong>parcourir</strong>"
44
44
  dropzone_hint: "Fichiers CSV, JSON ou XLSX acceptés"
45
45
  select_prompt: "Sélectionner..."
46
+ header_row_label: "Ligne des en-têtes"
47
+ header_row_hint: "Numéro de la ligne (à partir de 1) contenant les en-têtes de colonnes. Laissez 1 sauf si votre fichier a des lignes de description avant les en-têtes."
46
48
  mapping:
47
49
  load_template: "Charger un modèle"
48
50
  save_as_template: "Sauvegarder comme modèle"
@@ -124,3 +126,4 @@ fr:
124
126
  file_required: "doit être joint pour les imports %{source}"
125
127
  file_too_large: "est trop volumineux (max %{max} Mo)"
126
128
  invalid_content_type: "a un type de contenu invalide (%{type})"
129
+ file_extension_mismatch: "fichier %{expected} attendu mais %{actual} reçu"
@@ -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
@@ -72,7 +72,7 @@ module DataPorter
72
72
  end
73
73
 
74
74
  def cell_value(record, col)
75
- (record.data[col.name.to_s] || record.data[col.name]).to_s
75
+ record.data[col.name.to_s].to_s
76
76
  end
77
77
 
78
78
  def row_attrs(record)
@@ -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
@@ -5,6 +5,7 @@ module DataPorter
5
5
  def initialize(data_import)
6
6
  @data_import = data_import
7
7
  @target = data_import.target_class.new
8
+ @target.import_params = data_import.config&.dig("import_params") || {}
8
9
  @revalidator = RecordRevalidator.new(@target)
9
10
  end
10
11
 
@@ -32,10 +33,7 @@ module DataPorter
32
33
  record.data[key] = value
33
34
  end
34
35
 
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
-
36
+ def resolve_key(_data, column)
39
37
  column.to_s
40
38
  end
41
39
 
@@ -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
@@ -16,15 +16,7 @@ module DataPorter
16
16
  end
17
17
 
18
18
  def available
19
- @targets.map do |key, klass|
20
- {
21
- key: key,
22
- label: klass._label,
23
- icon: klass._icon,
24
- sources: klass._sources || DataPorter.configuration.enabled_sources,
25
- params: serialize_params(klass._params)
26
- }
27
- end
19
+ @targets.map { |key, klass| serialize_target(key, klass) }
28
20
  end
29
21
 
30
22
  def refresh!
@@ -37,6 +29,17 @@ module DataPorter
37
29
 
38
30
  private
39
31
 
32
+ def serialize_target(key, klass)
33
+ {
34
+ key: key,
35
+ label: klass._label,
36
+ icon: klass._icon,
37
+ sources: klass._sources || DataPorter.configuration.enabled_sources,
38
+ params: serialize_params(klass._params),
39
+ header_row: (klass._header_row || 0) + 1
40
+ }
41
+ end
42
+
40
43
  def resolve_collection(collection)
41
44
  return unless collection
42
45
 
@@ -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,18 +32,18 @@ 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
47
  end
48
48
 
49
49
  def header_row_index
@@ -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
 
@@ -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!
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DataPorter
4
- VERSION = "2.9.0"
4
+ VERSION = "2.10.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.9.0
4
+ version: 2.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seryl Lounis
@@ -123,6 +123,7 @@ files:
123
123
  - app/assets/stylesheets/data_porter/progress.css
124
124
  - app/assets/stylesheets/data_porter/table.css
125
125
  - app/channels/data_porter/import_channel.rb
126
+ - app/controllers/data_porter/concerns/import_params_builder.rb
126
127
  - app/controllers/data_porter/concerns/import_validation.rb
127
128
  - app/controllers/data_porter/concerns/mapping_management.rb
128
129
  - app/controllers/data_porter/concerns/record_pagination.rb