data_porter 2.9.1 → 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: a04398e443f704b747eff3f3b585e05f839cabd2f92ecf7871874811a079f15c
4
- data.tar.gz: ade55b5a550d628acaf5f8d0e8cc0652fabcb2c7505b59bd14367e7ab56557cf
3
+ metadata.gz: fb7f4978449eef549b221a5bb8c2e297ca19ce4a91161d80a18f02dce1e68555
4
+ data.tar.gz: 995a508847ef2ecf97796096a374e142026074662d349c9cb912272a4a6c0b0c
5
5
  SHA512:
6
- metadata.gz: 9647861c80be730b057a74854c91569bbc2e6e7d922cff5bffd28d7d4268f2a55e83bc08247b4ea93b2c9991e1ac2da1da3f4e71970b71225d9c08e5440ca7d6
7
- data.tar.gz: b96e83067441054e1a0140c7b826043984d7fcc0d6c7b7f7ee7fe2a09d416365670a7bce7ed1eeb703d6949ee9dd24d7c791b06978d12d8c52929fa17b4553c6
6
+ metadata.gz: 3660607affb95f6f4325f3175adf413c472a7fec30961919cc3138a7cdb5bea4ea94fe29ee894425ff596663ded515e9ba7d6d33a3c15ea97ccfccce4bbef8a1
7
+ data.tar.gz: 5691769ca62b86555a372ef942a0049b3373a7c2ed6585990189cc266a8047071ae13cd613189bb0bc6d07b7e939535cd7d82331bce7a1ca850ebb8da9a050b5
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ 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
+
10
21
  ## [2.9.1] - 2026-03-11
11
22
 
12
23
  ### Changed
@@ -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) {
@@ -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"
@@ -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
 
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DataPorter
4
- VERSION = "2.9.1"
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.1
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