data_porter 2.6.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: 955a886124d8ff2f1da4f23e725a52caec3bbc635450f058e3bbce81f4b898f5
4
- data.tar.gz: 6f1f0be41999d105c7558b7192f3f61d79f935431ce7b99bede5753d69be9ce3
3
+ metadata.gz: 4d3f44f3aff296ae20b7ac78c00d4a9c6f12b2477837a7323528b26c75fd03d3
4
+ data.tar.gz: 305de334f31a96b2e9c26e74ba58974420957df85d5169ce2de51a017b1f3622
5
5
  SHA512:
6
- metadata.gz: 399c87e6daa56196ae96525ca75b27a464a109463d3f4ebf5361738a93cc6385b30e37e588cab45c3a2218b99596dabcd9a48b0a9856eac17902907a9f4f9a34
7
- data.tar.gz: f423073b27fc407cc94ecc8e11693d40da5210387129f428105ac4973d7b7be034a59b8161c2a4da3c99acc7e4ec1b13d4828c54336e12d441044549b6243151
6
+ metadata.gz: 702e623e0983e44a9f4b149f47d8426d667cb553b12ed5eb781b99d8072944abe3bbf415e724d44ce9a086bf9b2dcf62efd031b83cfa5613b7ab5efe26604b43
7
+ data.tar.gz: 0a72e5ad9983833ffe451c076bafa07d1f433460b3b6020b6e9dcac0a20f263dbd73c0f796dfae3b52d08caeae11c531af510b70fd35880522da0ccda1a12e0a
data/CHANGELOG.md CHANGED
@@ -7,10 +7,49 @@ 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
+
26
+ ## [2.8.0] - 2026-03-08
27
+
10
28
  ### Added
11
29
 
12
30
  - **Auto-map heuristics** -- Smart column suggestions that pre-fill mapping selects when CSV/XLSX headers match target fields by exact name or built-in synonym (e.g. "E-mail Address" → email, "fname" → first_name). Supports per-column custom synonyms via `synonyms:` keyword in column DSL. Fallback chain: saved mapping > code-defined > auto-map > empty
13
31
 
32
+ ### Fixed
33
+
34
+ - Inline edit cell selector now uses `closest("td")` instead of `closest("[data-column]")` to avoid matching the input element itself
35
+
36
+ ## [2.7.0] - 2026-02-22
37
+
38
+ ### Added
39
+
40
+ - **Inline edit in preview** -- Click any cell in the preview table to fix validation errors directly, without re-uploading the file. Optimistic UI with background validation: click cell → input appears → edit → blur/Enter → value updates instantly → PATCH to server → re-validate → update status, errors, and summary cards
41
+ - `RecordRevalidator` service -- Re-validates an existing record after data change using the same pipeline as initial parse (transforms + target validation + built-in validation), with JSONB string-key normalization for post-persistence records
42
+ - `RecordUpdater` service -- Orchestrates cell update, re-validation, delta-based report update, and persistence. Returns transformed value, status, errors, and updated report counts
43
+ - `PATCH :update_record` route and controller action with `ensure_previewing` guard
44
+ - Stimulus `inline_edit_controller` -- Click-to-edit cells with Enter/blur to save, Escape to cancel, Tab/Shift+Tab to navigate between cells. Updates row status, errors cell, and summary card counts from server response
45
+ - Editable mode on `Preview::Table` and `Preview::SummaryCards` Phlex components -- `editable: true` renders Stimulus controller, click actions, data attributes on cells. Non-editable mode (completed/failed views) renders exactly as before
46
+ - Inline edit CSS -- editable cell hover, inline input, saving/error/success animation states
47
+ - `edit_cell` and `saving` locale keys (en + fr)
48
+
49
+ ### Changed
50
+
51
+ - 638 RSpec examples (up from 574), 0 failures
52
+
14
53
  ## [2.6.0] - 2026-02-21
15
54
 
16
55
  ### Added
@@ -0,0 +1,199 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["cell", "errors", "summary"]
5
+ static values = { updateUrl: String }
6
+
7
+ edit(event) {
8
+ var cell = event.currentTarget
9
+ if (cell.querySelector(".dp-inline-input")) return
10
+
11
+ var value = cell.dataset.value || ""
12
+ cell.dataset.originalValue = value
13
+ cell.textContent = ""
14
+ cell.classList.add("dp-cell--editing")
15
+
16
+ var isLong = value.length > 50
17
+ var input = document.createElement(isLong ? "textarea" : "input")
18
+ if (!isLong) input.type = "text"
19
+ input.className = "dp-inline-input"
20
+ input.value = value
21
+ input.dataset.lineNumber = cell.dataset.lineNumber
22
+ input.dataset.column = cell.dataset.column
23
+
24
+ var self = this
25
+ input._blurHandler = function(e) { self.save(e) }
26
+ input._keyHandler = function(e) { self.handleKey(e, isLong) }
27
+ input.addEventListener("blur", input._blurHandler)
28
+ input.addEventListener("keydown", input._keyHandler)
29
+ cell.appendChild(input)
30
+ this.autoSize(input)
31
+ if (isLong) this.autoHeight(input)
32
+ input.addEventListener("input", function() {
33
+ self.autoSize(input)
34
+ if (isLong) self.autoHeight(input)
35
+ })
36
+ input.focus()
37
+ input.select()
38
+ }
39
+
40
+ handleKey(event, isTextarea) {
41
+ if (event.key === "Enter" && (!isTextarea || event.ctrlKey || event.metaKey)) {
42
+ event.preventDefault()
43
+ event.target.blur()
44
+ } else if (event.key === "Escape") {
45
+ event.preventDefault()
46
+ this.cancel(event.target)
47
+ } else if (event.key === "Tab") {
48
+ event.preventDefault()
49
+ var cell = event.target.closest("td")
50
+ event.target.blur()
51
+ this.moveToNext(cell, event.shiftKey)
52
+ }
53
+ }
54
+
55
+ cancel(input) {
56
+ var cell = input.closest("td")
57
+ input.removeEventListener("blur", input._blurHandler)
58
+ input.removeEventListener("keydown", input._keyHandler)
59
+ cell.classList.remove("dp-cell--editing")
60
+ var original = cell.dataset.originalValue || ""
61
+ cell.textContent = original
62
+ delete cell.dataset.originalValue
63
+ cell.blur()
64
+ }
65
+
66
+ save(event) {
67
+ var input = event.target
68
+ var cell = input.closest("td")
69
+ if (!cell) return
70
+
71
+ input.removeEventListener("blur", input._blurHandler)
72
+ input.removeEventListener("keydown", input._keyHandler)
73
+
74
+ var newValue = input.value
75
+ var original = cell.dataset.originalValue
76
+
77
+ cell.classList.remove("dp-cell--editing")
78
+
79
+ if (newValue === original) {
80
+ cell.textContent = original
81
+ delete cell.dataset.originalValue
82
+ cell.blur()
83
+ return
84
+ }
85
+
86
+ cell.textContent = newValue
87
+ cell.classList.add("dp-cell--saving")
88
+ delete cell.dataset.originalValue
89
+ cell.blur()
90
+
91
+ this.patch(cell, {
92
+ line_number: parseInt(cell.dataset.lineNumber, 10),
93
+ column: cell.dataset.column,
94
+ value: newValue
95
+ })
96
+ }
97
+
98
+ async patch(cell, body) {
99
+ try {
100
+ var csrfMeta = document.querySelector("meta[name='csrf-token']")
101
+ var headers = {
102
+ "Content-Type": "application/json",
103
+ "Accept": "application/json"
104
+ }
105
+ if (csrfMeta) headers["X-CSRF-Token"] = csrfMeta.content
106
+
107
+ var response = await fetch(this.updateUrlValue, {
108
+ method: "PATCH",
109
+ headers: headers,
110
+ body: JSON.stringify(body)
111
+ })
112
+
113
+ if (!response.ok) throw new Error("Request failed")
114
+
115
+ var data = await response.json()
116
+ this.applyResponse(cell, data)
117
+ } catch (e) {
118
+ cell.classList.remove("dp-cell--saving")
119
+ cell.classList.add("dp-cell--error")
120
+ setTimeout(function() { cell.classList.remove("dp-cell--error") }, 2000)
121
+ }
122
+ }
123
+
124
+ applyResponse(cell, data) {
125
+ cell.classList.remove("dp-cell--saving")
126
+ cell.classList.add("dp-cell--success")
127
+ setTimeout(function() { cell.classList.remove("dp-cell--success") }, 800)
128
+
129
+ cell.textContent = data.value
130
+ cell.dataset.value = data.value
131
+
132
+ var lineNumber = cell.dataset.lineNumber
133
+ this.updateRowStatus(lineNumber, data.status)
134
+ this.updateErrors(lineNumber, data.errors)
135
+ this.updateSummary(data.report)
136
+ }
137
+
138
+ updateRowStatus(lineNumber, status) {
139
+ var row = this.element.querySelector("tr[data-line-number='" + lineNumber + "']")
140
+ if (!row) return
141
+
142
+ row.className = row.className.replace(/dp-row--\w+/, "dp-row--" + status)
143
+ var statusCell = row.children[1]
144
+ if (statusCell) statusCell.textContent = status
145
+ }
146
+
147
+ updateErrors(lineNumber, errors) {
148
+ var errorsCell = this.errorsTargets.find(function(el) {
149
+ return el.dataset.lineNumber === String(lineNumber)
150
+ })
151
+ if (errorsCell) errorsCell.textContent = (errors || []).join(", ")
152
+ }
153
+
154
+ updateSummary(report) {
155
+ if (!report) return
156
+
157
+ this.summaryTargets.forEach(function(el) {
158
+ var key = el.dataset.countKey
159
+ if (key && report[key] !== undefined) {
160
+ el.textContent = String(report[key])
161
+ }
162
+ })
163
+ }
164
+
165
+ autoSize(input) {
166
+ var span = document.createElement("span")
167
+ span.style.cssText = "position:absolute;visibility:hidden;white-space:pre;"
168
+ var computed = window.getComputedStyle(input)
169
+ span.style.font = computed.font
170
+ span.style.padding = computed.padding
171
+ span.style.border = computed.border
172
+ span.textContent = input.value || " "
173
+ document.body.appendChild(span)
174
+ var cell = input.closest("td")
175
+ var minWidth = cell ? cell.offsetWidth : 100
176
+ var tableRight = this.element.getBoundingClientRect().right
177
+ var cellLeft = cell.getBoundingClientRect().left
178
+ var maxWidth = tableRight - cellLeft - 4
179
+ var width = Math.min(Math.max(minWidth, span.offsetWidth + 4), maxWidth)
180
+ input.style.width = width + "px"
181
+ document.body.removeChild(span)
182
+ }
183
+
184
+ autoHeight(textarea) {
185
+ textarea.style.height = "auto"
186
+ textarea.style.height = textarea.scrollHeight + "px"
187
+ }
188
+
189
+ moveToNext(currentCell, reverse) {
190
+ var cells = Array.from(this.cellTargets)
191
+ var index = cells.indexOf(currentCell)
192
+ if (index === -1) return
193
+
194
+ var next = reverse ? index - 1 : index + 1
195
+ if (next >= 0 && next < cells.length) {
196
+ cells[next].click()
197
+ }
198
+ }
199
+ }
@@ -9,5 +9,6 @@
9
9
  *= require data_porter/alerts
10
10
  *= require data_porter/modal
11
11
  *= require data_porter/mapping
12
+ *= require data_porter/inline_edit
12
13
  *= require data_porter/dark
13
14
  */
@@ -0,0 +1,130 @@
1
+ .dp-cell--editable {
2
+ cursor: pointer;
3
+ position: relative;
4
+ outline: none;
5
+ transition: background 0.15s ease;
6
+ }
7
+
8
+ .dp-cell--editable:focus,
9
+ .dp-cell--editable:focus-visible {
10
+ outline: none;
11
+ box-shadow: none;
12
+ }
13
+
14
+ .dp-cell--editable:hover:not(.dp-cell--editing):not(.dp-cell--saving) {
15
+ background: var(--dp-primary-light);
16
+ }
17
+
18
+ .dp-cell--editable:hover:not(.dp-cell--editing):not(.dp-cell--saving)::after {
19
+ content: "\f303";
20
+ font-family: "Font Awesome 6 Free";
21
+ font-weight: 900;
22
+ font-size: 0.65rem;
23
+ position: absolute;
24
+ top: 0.25rem;
25
+ right: 0.35rem;
26
+ color: var(--dp-primary);
27
+ opacity: 0.6;
28
+ pointer-events: none;
29
+ }
30
+
31
+ .dp-cell--editing {
32
+ padding: 0 !important;
33
+ background: transparent !important;
34
+ outline: none !important;
35
+ overflow: visible;
36
+ }
37
+
38
+ .dp-inline-input {
39
+ position: absolute;
40
+ top: 0;
41
+ left: 0;
42
+ padding: 0.25rem 0.375rem;
43
+ border: 2px solid var(--dp-primary);
44
+ border-radius: var(--dp-radius-sm);
45
+ font-size: inherit;
46
+ font-family: inherit;
47
+ line-height: inherit;
48
+ background: var(--dp-bg);
49
+ color: var(--dp-text);
50
+ outline: none;
51
+ box-sizing: border-box;
52
+ z-index: 10;
53
+ }
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
+
63
+ .dp-inline-input:focus {
64
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15);
65
+ }
66
+
67
+ .dp-cell--saving {
68
+ opacity: 0.6;
69
+ pointer-events: none;
70
+ }
71
+
72
+ .dp-cell--saving::after {
73
+ content: "\f110";
74
+ font-family: "Font Awesome 6 Free";
75
+ font-weight: 900;
76
+ font-size: 0.7rem;
77
+ position: absolute;
78
+ top: 0.25rem;
79
+ right: 0.35rem;
80
+ color: var(--dp-primary);
81
+ animation: dp-spin 0.8s linear infinite;
82
+ }
83
+
84
+ .dp-cell--error {
85
+ outline: 2px solid var(--dp-danger) !important;
86
+ background: var(--dp-danger-light);
87
+ }
88
+
89
+ .dp-cell--error::after {
90
+ content: "\f00d";
91
+ font-family: "Font Awesome 6 Free";
92
+ font-weight: 900;
93
+ font-size: 0.7rem;
94
+ position: absolute;
95
+ top: 0.25rem;
96
+ right: 0.35rem;
97
+ color: var(--dp-danger);
98
+ }
99
+
100
+ .dp-cell--success {
101
+ animation: dp-flash-success 0.8s ease;
102
+ }
103
+
104
+ .dp-cell--success::after {
105
+ content: "\f00c";
106
+ font-family: "Font Awesome 6 Free";
107
+ font-weight: 900;
108
+ font-size: 0.7rem;
109
+ position: absolute;
110
+ top: 0.25rem;
111
+ right: 0.35rem;
112
+ color: var(--dp-success);
113
+ animation: dp-fade-out 0.8s ease forwards;
114
+ }
115
+
116
+ @keyframes dp-flash-success {
117
+ 0% { background: var(--dp-success-light); }
118
+ 100% { background: transparent; }
119
+ }
120
+
121
+ @keyframes dp-fade-out {
122
+ 0% { opacity: 1; }
123
+ 70% { opacity: 1; }
124
+ 100% { opacity: 0; }
125
+ }
126
+
127
+ @keyframes dp-spin {
128
+ from { transform: rotate(0deg); }
129
+ to { transform: rotate(360deg); }
130
+ }
@@ -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
@@ -10,7 +10,9 @@ module DataPorter
10
10
  layout "data_porter/application"
11
11
 
12
12
  before_action :set_import, only: %i[show parse confirm cancel dry_run update_mapping
13
- status export_rejects destroy back_to_mapping resume]
13
+ update_record status export_rejects destroy
14
+ back_to_mapping resume]
15
+ before_action :ensure_previewing, only: :update_record
14
16
  before_action :load_targets, only: %i[index new create]
15
17
 
16
18
  def index
@@ -81,6 +83,16 @@ module DataPorter
81
83
  redirect_to import_path(@import)
82
84
  end
83
85
 
86
+ def update_record
87
+ updater = RecordUpdater.new(@import)
88
+ result = updater.call(
89
+ line_number: params[:line_number].to_i,
90
+ column: params[:column],
91
+ value: params[:value]
92
+ )
93
+ render json: result
94
+ end
95
+
84
96
  def status
85
97
  progress = @import.config["progress"] || {}
86
98
  render json: { status: @import.status, progress: progress }
@@ -104,6 +116,12 @@ module DataPorter
104
116
  @import = scoped_imports.find(params[:id])
105
117
  end
106
118
 
119
+ def ensure_previewing
120
+ return if @import.previewing?
121
+
122
+ render json: { error: "Import is not in previewing state" }, status: :unprocessable_entity
123
+ end
124
+
107
125
  def scoped_imports
108
126
  owner = resolve_owner
109
127
  return DataPorter::DataImport.all unless owner
@@ -46,10 +46,12 @@
46
46
 
47
47
  <% if @import.previewing? %>
48
48
  <div id="records">
49
- <%= raw DataPorter::Components::Preview::SummaryCards.new(report: @import.report).call %>
49
+ <%= raw DataPorter::Components::Preview::SummaryCards.new(report: @import.report, editable: true).call %>
50
50
  <%= raw DataPorter::Components::Preview::Table.new(
51
51
  columns: @target._columns,
52
- records: @records
52
+ records: @records,
53
+ editable: true,
54
+ update_url: update_record_import_path(@import)
53
55
  ).call %>
54
56
  </div>
55
57
  <%= raw DataPorter::Components::Shared::Pagination.new(
@@ -16,7 +16,8 @@
16
16
  "data_porter/template_form_controller": "<%= asset_path('data_porter/template_form_controller.js') %>",
17
17
  "data_porter/progress_controller": "<%= asset_path('data_porter/progress_controller.js') %>",
18
18
  "data_porter/import_form_controller": "<%= asset_path('data_porter/import_form_controller.js') %>",
19
- "data_porter/theme_controller": "<%= asset_path('data_porter/theme_controller.js') %>"
19
+ "data_porter/theme_controller": "<%= asset_path('data_porter/theme_controller.js') %>",
20
+ "data_porter/inline_edit_controller": "<%= asset_path('data_porter/inline_edit_controller.js') %>"
20
21
  }
21
22
  }
22
23
  </script>
@@ -28,6 +29,7 @@
28
29
  import ProgressController from "data_porter/progress_controller"
29
30
  import ImportFormController from "data_porter/import_form_controller"
30
31
  import ThemeController from "data_porter/theme_controller"
32
+ import InlineEditController from "data_porter/inline_edit_controller"
31
33
 
32
34
  const application = Application.start()
33
35
  application.register("data-porter--mapping", MappingController)
@@ -35,6 +37,7 @@
35
37
  application.register("data-porter--progress", ProgressController)
36
38
  application.register("data-porter--import-form", ImportFormController)
37
39
  application.register("data-porter--theme", ThemeController)
40
+ application.register("data-porter--inline-edit", InlineEditController)
38
41
  </script>
39
42
  </head>
40
43
  <body data-controller="data-porter--theme">
@@ -18,6 +18,8 @@ en:
18
18
  back_to_mapping: "Back to Mapping"
19
19
  download_rejects: "Download rejects CSV"
20
20
  processing: "Processing..."
21
+ edit_cell: "Click to edit"
22
+ saving: "Saving..."
21
23
  no_imports: "No imports yet"
22
24
  create_first: "Create your first import"
23
25
  details:
@@ -18,6 +18,8 @@ fr:
18
18
  back_to_mapping: "Retour au mapping"
19
19
  download_rejects: "Télécharger les rejets CSV"
20
20
  processing: "Traitement..."
21
+ edit_cell: "Cliquer pour modifier"
22
+ saving: "Enregistrement..."
21
23
  no_imports: "Aucun import"
22
24
  create_first: "Créer votre premier import"
23
25
  details:
data/config/routes.rb CHANGED
@@ -11,7 +11,10 @@ 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
17
+ patch :update_record
15
18
  get :status
16
19
  get :export_rejects
17
20
  end
@@ -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 }
@@ -4,29 +4,42 @@ module DataPorter
4
4
  module Components
5
5
  module Preview
6
6
  class SummaryCards < Base
7
- def initialize(report:)
7
+ CARDS = [
8
+ { css: "dp-card--complete", key: :complete_count, i18n: "ready" },
9
+ { css: "dp-card--partial", key: :partial_count, i18n: "incomplete" },
10
+ { css: "dp-card--missing", key: :missing_count, i18n: "missing" },
11
+ { css: "dp-card--duplicate", key: :duplicate_count, i18n: "duplicates" }
12
+ ].freeze
13
+
14
+ def initialize(report:, editable: false)
8
15
  super()
9
16
  @report = report
17
+ @editable = editable
10
18
  end
11
19
 
12
20
  def view_template
13
21
  div(class: "dp-summary-cards") do
14
- card("dp-card--complete", @report.complete_count, I18n.t("data_porter.components.summary_cards.ready"))
15
- card("dp-card--partial", @report.partial_count, I18n.t("data_porter.components.summary_cards.incomplete"))
16
- card("dp-card--missing", @report.missing_count, I18n.t("data_porter.components.summary_cards.missing"))
17
- card("dp-card--duplicate", @report.duplicate_count,
18
- I18n.t("data_porter.components.summary_cards.duplicates"))
22
+ CARDS.each { |card_def| card(card_def) }
19
23
  end
20
24
  end
21
25
 
22
26
  private
23
27
 
24
- def card(css_class, count, label)
25
- div(class: "dp-card #{css_class}") do
26
- strong { count.to_s }
28
+ def card(card_def)
29
+ count = @report.send(card_def[:key])
30
+ label = I18n.t("data_porter.components.summary_cards.#{card_def[:i18n]}")
31
+
32
+ div(class: "dp-card #{card_def[:css]}") do
33
+ strong(**count_attrs(card_def[:key])) { count.to_s }
27
34
  plain " #{label}"
28
35
  end
29
36
  end
37
+
38
+ def count_attrs(key)
39
+ return {} unless @editable
40
+
41
+ { data: { "data-porter--inline-edit-target": "summary", count_key: key } }
42
+ end
30
43
  end
31
44
  end
32
45
  end
@@ -4,14 +4,16 @@ module DataPorter
4
4
  module Components
5
5
  module Preview
6
6
  class Table < Base
7
- def initialize(columns:, records:)
7
+ def initialize(columns:, records:, editable: false, update_url: nil)
8
8
  super()
9
9
  @columns = columns
10
10
  @records = records
11
+ @editable = editable
12
+ @update_url = update_url
11
13
  end
12
14
 
13
15
  def view_template
14
- div(class: "dp-preview-table") do
16
+ div(**wrapper_attrs) do
15
17
  table(class: "dp-table") do
16
18
  render_header
17
19
  render_body
@@ -21,6 +23,17 @@ module DataPorter
21
23
 
22
24
  private
23
25
 
26
+ def wrapper_attrs
27
+ attrs = { class: "dp-preview-table" }
28
+ return attrs unless @editable
29
+
30
+ attrs[:data] = {
31
+ controller: "data-porter--inline-edit",
32
+ "data-porter--inline-edit-update-url-value": @update_url
33
+ }
34
+ attrs
35
+ end
36
+
24
37
  def render_header
25
38
  thead do
26
39
  tr do
@@ -39,14 +52,63 @@ module DataPorter
39
52
  end
40
53
 
41
54
  def render_row(record)
42
- tr(class: "dp-row--#{record.status}") do
55
+ tr(**row_attrs(record)) do
43
56
  td { record.line_number.to_s }
44
57
  td { record.status }
45
- @columns.each { |col| td { record.data[col.name.to_s].to_s } }
46
- td(class: "dp-errors") { error_messages(record) }
58
+ @columns.each { |col| render_cell(record, col) }
59
+ render_errors_cell(record)
47
60
  end
48
61
  end
49
62
 
63
+ def render_cell(record, col)
64
+ value = cell_value(record, col)
65
+ attrs = cell_attrs(record, col, value)
66
+ attrs[:title] = value if value.length > 30
67
+ td(**attrs) { value }
68
+ end
69
+
70
+ def render_errors_cell(record)
71
+ td(**errors_cell_attrs(record)) { error_messages(record) }
72
+ end
73
+
74
+ def cell_value(record, col)
75
+ (record.data[col.name.to_s] || record.data[col.name]).to_s
76
+ end
77
+
78
+ def row_attrs(record)
79
+ attrs = { class: "dp-row--#{record.status}" }
80
+ return attrs unless @editable
81
+
82
+ attrs[:data] = { line_number: record.line_number }
83
+ attrs
84
+ end
85
+
86
+ def cell_attrs(record, col, value)
87
+ return {} unless @editable
88
+
89
+ {
90
+ class: "dp-cell--editable",
91
+ data: {
92
+ action: "click->data-porter--inline-edit#edit",
93
+ "data-porter--inline-edit-target": "cell",
94
+ value: value,
95
+ line_number: record.line_number,
96
+ column: col.name.to_s
97
+ }
98
+ }
99
+ end
100
+
101
+ def errors_cell_attrs(record)
102
+ attrs = { class: "dp-errors" }
103
+ return attrs unless @editable
104
+
105
+ attrs[:data] = {
106
+ "data-porter--inline-edit-target": "errors",
107
+ line_number: record.line_number
108
+ }
109
+ attrs
110
+ end
111
+
50
112
  def error_messages(record)
51
113
  record.errors_list.map(&:message).join(", ")
52
114
  end
@@ -19,6 +19,7 @@ module DataPorter
19
19
  data_porter/progress_controller.js
20
20
  data_porter/import_form_controller.js
21
21
  data_porter/theme_controller.js
22
+ data_porter/inline_edit_controller.js
22
23
  ]
23
24
  end
24
25
  end
@@ -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)
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ class RecordRevalidator
5
+ def initialize(target)
6
+ @target = target
7
+ @columns = target.class._columns || []
8
+ @validator = RecordValidator.new(@columns)
9
+ end
10
+
11
+ def call(record)
12
+ normalize_keys(record)
13
+ ColumnTransformer.apply_all(record, @columns)
14
+ @target.transform(record)
15
+ record.errors_list = []
16
+ @target.validate(record)
17
+ @validator.validate(record)
18
+ record.determine_status!
19
+ record
20
+ end
21
+
22
+ private
23
+
24
+ def normalize_keys(record)
25
+ @columns.each do |col|
26
+ string_key = col.name.to_s
27
+ next unless record.data.key?(string_key) && !record.data.key?(col.name)
28
+
29
+ record.data[col.name] = record.data.delete(string_key)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ class RecordUpdater
5
+ def initialize(data_import)
6
+ @data_import = data_import
7
+ @target = data_import.target_class.new
8
+ @revalidator = RecordRevalidator.new(@target)
9
+ end
10
+
11
+ def call(line_number:, column:, value:)
12
+ record = find_record(line_number)
13
+ old_status = record.status
14
+ update_cell(record, column, value)
15
+ @revalidator.call(record)
16
+ update_report(old_status, record.status)
17
+ persist!
18
+ build_response(record, column)
19
+ end
20
+
21
+ private
22
+
23
+ def find_record(line_number)
24
+ record = @data_import.records.find { |r| r.line_number == line_number }
25
+ raise Error, "Record #{line_number} not found" unless record
26
+
27
+ record
28
+ end
29
+
30
+ def update_cell(record, column, value)
31
+ key = resolve_key(record.data, column)
32
+ record.data[key] = value
33
+ end
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
+
39
+ column.to_s
40
+ end
41
+
42
+ def update_report(old_status, new_status)
43
+ return if old_status == new_status
44
+
45
+ report = @data_import.report
46
+ decrement_count(report, old_status)
47
+ increment_count(report, new_status)
48
+ end
49
+
50
+ def decrement_count(report, status)
51
+ attr = count_attribute(status)
52
+ report.send(:"#{attr}=", [report.send(attr) - 1, 0].max)
53
+ end
54
+
55
+ def increment_count(report, status)
56
+ attr = count_attribute(status)
57
+ report.send(:"#{attr}=", report.send(attr) + 1)
58
+ end
59
+
60
+ def count_attribute(status)
61
+ {
62
+ "complete" => :complete_count,
63
+ "partial" => :partial_count,
64
+ "missing" => :missing_count
65
+ }.fetch(status, :partial_count)
66
+ end
67
+
68
+ def persist!
69
+ @data_import.update!(
70
+ records: @data_import.records,
71
+ report: @data_import.report
72
+ )
73
+ end
74
+
75
+ def build_response(record, column)
76
+ key = resolve_key(record.data, column)
77
+ {
78
+ status: record.status,
79
+ value: record.data[key].to_s,
80
+ errors: record.errors_list.map(&:message),
81
+ report: report_hash
82
+ }
83
+ end
84
+
85
+ def report_hash
86
+ r = @data_import.report
87
+ {
88
+ complete_count: r.complete_count,
89
+ partial_count: r.partial_count,
90
+ missing_count: r.missing_count,
91
+ duplicate_count: r.duplicate_count
92
+ }
93
+ end
94
+ end
95
+ end
@@ -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.6.0"
4
+ VERSION = "2.9.0"
5
5
  end
data/lib/data_porter.rb CHANGED
@@ -17,6 +17,8 @@ require_relative "data_porter/target"
17
17
  require_relative "data_porter/registry"
18
18
  require_relative "data_porter/sources"
19
19
  require_relative "data_porter/record_validator"
20
+ require_relative "data_porter/record_revalidator"
21
+ require_relative "data_porter/record_updater"
20
22
  require_relative "data_porter/broadcaster"
21
23
  require_relative "data_porter/webhook_notifier"
22
24
  require_relative "data_porter/orchestrator"
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.6.0
4
+ version: 2.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seryl Lounis
@@ -102,6 +102,7 @@ files:
102
102
  - ROADMAP.md
103
103
  - Rakefile
104
104
  - app/assets/javascripts/data_porter/import_form_controller.js
105
+ - app/assets/javascripts/data_porter/inline_edit_controller.js
105
106
  - app/assets/javascripts/data_porter/mapping_controller.js
106
107
  - app/assets/javascripts/data_porter/progress_controller.js
107
108
  - app/assets/javascripts/data_porter/stimulus.min.js
@@ -114,6 +115,7 @@ files:
114
115
  - app/assets/stylesheets/data_porter/base.css
115
116
  - app/assets/stylesheets/data_porter/cards.css
116
117
  - app/assets/stylesheets/data_porter/dark.css
118
+ - app/assets/stylesheets/data_porter/inline_edit.css
117
119
  - app/assets/stylesheets/data_porter/layout.css
118
120
  - app/assets/stylesheets/data_porter/mapping.css
119
121
  - app/assets/stylesheets/data_porter/modal.css
@@ -172,6 +174,8 @@ files:
172
174
  - lib/data_porter/orchestrator/dry_runner.rb
173
175
  - lib/data_porter/orchestrator/importer.rb
174
176
  - lib/data_porter/orchestrator/record_builder.rb
177
+ - lib/data_porter/record_revalidator.rb
178
+ - lib/data_porter/record_updater.rb
175
179
  - lib/data_porter/record_validator.rb
176
180
  - lib/data_porter/registry.rb
177
181
  - lib/data_porter/rejects_csv_builder.rb