data_porter 0.4.0 → 0.5.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: cf5cdd3a072250ed1b6f8066766f4bb94c0447c80f993de6031f562ef29c969f
4
- data.tar.gz: c098c9c856c42c7f0cbdb2f0e4efef8fa298f21bac79ecc8abeb8d0c9435160a
3
+ metadata.gz: e99d5974ba60bb61902d1dcf98ad74d940fd82344ec88404fa55e2414cee3fc0
4
+ data.tar.gz: 7166547614a1a5942648e85d900d30abe053b4ba4f066811bb71389b83359531
5
5
  SHA512:
6
- metadata.gz: '08d4f1bc3867112f83a6637129d6e34373e5f5b29913e116cc822fdd150447eb047cac185bb2be5c05180340672ded8c712b8f2e0ec14ca28f71c3c653381898'
7
- data.tar.gz: a8808881059e7a84fa37042a52999462c27fca0da39bfdc3828c11e4eab88d095617394443baf6974c49e6f9bb0404dd861b561d6f5f6e3c40aafe5018d0633c
6
+ metadata.gz: 66860e2fb095ccd684e9899d8209fa4ae26b4c79595792bc3ebc4714a34b4a0605fab7e8c1e3a3e7698ad2b40e1cfbd06b58e7b80c995fab50091c88e547a2f6
7
+ data.tar.gz: 3864e610fbfce43d2ed67fdc5eab5b8d41920f6f79644fa549875f9bac8a96d2eae88cc358af8b74c85c2b374dc7486c728da9afb9ee786027de327d726e1b61
data/CHANGELOG.md CHANGED
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.0] - 2026-02-07
9
+
10
+ ### Added
11
+
12
+ - **JSON polling progress** -- Lightweight `GET /imports/:id/status` endpoint replaces ActionCable dependency for zero-config real-time progress tracking
13
+ - **Progress persistence** -- Orchestrator persists progress to `config["progress"]` via `update_column` for reliable status reporting
14
+ - **Dynamic progress labels** -- Progress bar shows contextual labels (Waiting, Parsing, Importing, Dry run) with animated gradient and shimmer effect
15
+ - **Results summary redesign** -- Completed imports show icon, stat cards (imported/errors), and duration
16
+ - **Imported records table** -- Completed import page displays the full records table
17
+ - **Submit button spinners** -- Confirm/Dry Run buttons disable and show spinner on click to prevent double submission
18
+ - **Import deletion** -- Delete button on completed/failed imports (index and show pages) with confirmation dialog
19
+ - **Automatic purge** -- `rake data_porter:purge` task removes completed/failed imports older than `purge_after` (default: 60 days)
20
+ - **`purge_after` configuration** -- Customizable retention period via `config.purge_after = 60.days` in initializer
21
+ - **Per-target source filtering** -- Source type dropdown filters to only show sources allowed by the selected target's `sources` DSL
22
+ - **Server-side source validation** -- Controller rejects source types not allowed by the target
23
+ - **Generator `--sources` option** -- `rails g data_porter:target Foo name:string --sources csv xlsx` generates target with specific sources
24
+
25
+ ### Changed
26
+
27
+ - Confirm and dry_run actions set status to `pending` before enqueuing jobs to prevent race conditions
28
+ - Progress controller rewritten as JSON poller (1s interval) with auto-redirect via `Turbo.visit` on completion
29
+ - ResultsSummary component uses Unicode escapes for Phlex-safe icons
30
+ - 296 RSpec examples (up from 280), 0 failures
31
+
8
32
  ## [0.4.0] - 2026-02-07
9
33
 
10
34
  ### Added
@@ -1,13 +1,71 @@
1
1
  .dp-results {
2
- padding: 1.5rem;
3
- background: var(--dp-success-light);
4
- border: 1px solid var(--dp-success-border);
2
+ padding: 2rem;
5
3
  border-radius: var(--dp-radius-lg);
6
4
  margin-bottom: 2rem;
7
5
  box-shadow: var(--dp-shadow-sm);
6
+ text-align: center;
7
+ }
8
+
9
+ .dp-results--success {
10
+ background: var(--dp-success-light);
11
+ border: 1px solid var(--dp-success-border);
12
+ }
13
+
14
+ .dp-results--partial {
15
+ background: var(--dp-warning-light);
16
+ border: 1px solid var(--dp-warning-border);
17
+ }
18
+
19
+ .dp-results__icon {
20
+ font-size: 2.5rem;
21
+ margin-bottom: 0.75rem;
22
+ }
23
+
24
+ .dp-results__title {
25
+ font-size: 1.25rem;
26
+ font-weight: 700;
27
+ margin: 0 0 1.25rem;
28
+ color: var(--dp-gray-900);
29
+ }
30
+
31
+ .dp-results__cards {
32
+ display: grid;
33
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
34
+ gap: 1rem;
35
+ max-width: 400px;
36
+ margin: 0 auto;
8
37
  }
9
38
 
10
- .dp-results p { margin: 0.35rem 0; font-size: 0.9375rem; }
39
+ .dp-results__stat {
40
+ padding: 1rem;
41
+ border-radius: var(--dp-radius-md);
42
+ background: white;
43
+ border: 1px solid var(--dp-gray-200);
44
+ }
45
+
46
+ .dp-results__stat strong {
47
+ display: block;
48
+ font-size: 1.75rem;
49
+ font-weight: 700;
50
+ letter-spacing: -0.025em;
51
+ }
52
+
53
+ .dp-results__stat span {
54
+ font-size: 0.75rem;
55
+ font-weight: 500;
56
+ text-transform: uppercase;
57
+ letter-spacing: 0.05em;
58
+ color: var(--dp-gray-500);
59
+ }
60
+
61
+ .dp-results__stat--success strong { color: var(--dp-success); }
62
+ .dp-results__stat--error strong { color: var(--dp-danger); }
63
+
64
+ .dp-results__duration {
65
+ margin-top: 1rem;
66
+ font-size: 0.8125rem;
67
+ color: var(--dp-gray-500);
68
+ }
11
69
 
12
70
  .dp-alert {
13
71
  padding: 1.25rem 1.5rem;
@@ -74,6 +74,23 @@
74
74
  }
75
75
 
76
76
  .dp-btn:active { transform: scale(0.98); }
77
+ .dp-btn:disabled { opacity: 0.65; cursor: not-allowed; transform: none; }
78
+
79
+ .dp-btn .dp-spinner {
80
+ display: inline-block;
81
+ width: 1em;
82
+ height: 1em;
83
+ margin-right: 0.5em;
84
+ border: 2px solid currentColor;
85
+ border-right-color: transparent;
86
+ border-radius: 50%;
87
+ animation: dp-spin 0.6s linear infinite;
88
+ vertical-align: -0.125em;
89
+ }
90
+
91
+ @keyframes dp-spin {
92
+ to { transform: rotate(360deg); }
93
+ }
77
94
 
78
95
  .dp-btn--primary { background: var(--dp-primary); color: white; box-shadow: var(--dp-shadow-sm); }
79
96
  .dp-btn--primary:hover { background: var(--dp-primary-hover); box-shadow: var(--dp-shadow-md); }
@@ -84,6 +101,8 @@
84
101
  .dp-btn--danger { background: var(--dp-danger); color: white; box-shadow: var(--dp-shadow-sm); }
85
102
  .dp-btn--danger:hover { background: var(--dp-danger-hover); box-shadow: var(--dp-shadow-md); }
86
103
 
104
+ .dp-btn--sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; }
105
+
87
106
  .dp-link { color: var(--dp-primary); text-decoration: none; font-weight: 500; transition: color 0.15s ease; }
88
107
  .dp-link:hover { color: var(--dp-primary-hover); text-decoration: underline; }
89
108
 
@@ -1,24 +1,44 @@
1
- .dp-progress {
1
+ .dp-progress-container {
2
2
  margin: 2rem 0;
3
- background: var(--dp-gray-200);
4
- border-radius: var(--dp-radius-xl);
3
+ padding: 2rem;
4
+ background: white;
5
+ border: 1px solid var(--dp-gray-200);
6
+ border-radius: var(--dp-radius-lg);
7
+ box-shadow: var(--dp-shadow-sm);
8
+ text-align: center;
9
+ }
10
+
11
+ .dp-progress-label {
12
+ font-size: 0.8125rem;
13
+ font-weight: 600;
14
+ color: var(--dp-gray-500);
15
+ margin-bottom: 1rem;
16
+ text-transform: uppercase;
17
+ letter-spacing: 0.05em;
18
+ }
19
+
20
+ .dp-progress {
21
+ background: var(--dp-gray-100);
22
+ border-radius: 9999px;
5
23
  overflow: hidden;
6
- height: 1.75rem;
7
- box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.08);
24
+ height: 1.5rem;
25
+ border: 1px solid var(--dp-gray-200);
8
26
  }
9
27
 
10
28
  .dp-progress-bar {
11
29
  height: 100%;
12
- background: linear-gradient(135deg, var(--dp-primary), #818cf8);
13
- border-radius: var(--dp-radius-xl);
14
- transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
30
+ background: linear-gradient(90deg, var(--dp-primary), #818cf8, var(--dp-primary));
31
+ background-size: 200% 100%;
32
+ animation: dp-gradient-flow 3s ease infinite;
33
+ border-radius: 9999px;
34
+ transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
15
35
  display: flex;
16
36
  align-items: center;
17
37
  justify-content: center;
18
38
  color: white;
19
- font-size: 0.75rem;
39
+ font-size: 0.6875rem;
20
40
  font-weight: 700;
21
- min-width: 2.5rem;
41
+ min-width: 2.25rem;
22
42
  position: relative;
23
43
  overflow: hidden;
24
44
  }
@@ -27,8 +47,13 @@
27
47
  content: "";
28
48
  position: absolute;
29
49
  inset: 0;
30
- background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
31
- animation: dp-shimmer 1.5s infinite;
50
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.25), transparent);
51
+ animation: dp-shimmer 2s infinite;
52
+ }
53
+
54
+ @keyframes dp-gradient-flow {
55
+ 0%, 100% { background-position: 0% 50%; }
56
+ 50% { background-position: 100% 50%; }
32
57
  }
33
58
 
34
59
  @keyframes dp-shimmer {
@@ -43,3 +43,9 @@
43
43
  .dp-table tbody tr:hover {
44
44
  background: var(--dp-gray-50);
45
45
  }
46
+
47
+ .dp-table__actions {
48
+ display: flex;
49
+ align-items: center;
50
+ gap: 0.5rem;
51
+ }
@@ -4,7 +4,7 @@ module DataPorter
4
4
  class ImportsController < DataPorter.configuration.parent_controller.constantize
5
5
  layout "data_porter/application"
6
6
 
7
- before_action :set_import, only: %i[show parse confirm cancel dry_run update_mapping]
7
+ before_action :set_import, only: %i[show parse confirm cancel dry_run update_mapping status destroy]
8
8
  before_action :load_targets, only: %i[index new create]
9
9
 
10
10
  def index
@@ -18,7 +18,7 @@ module DataPorter
18
18
  def create
19
19
  build_import
20
20
 
21
- if valid_file_presence? && @import.save
21
+ if valid_source_for_target? && valid_file_presence? && @import.save
22
22
  enqueue_after_create
23
23
  redirect_to import_path(@import)
24
24
  else
@@ -47,6 +47,7 @@ module DataPorter
47
47
  end
48
48
 
49
49
  def confirm
50
+ @import.update!(status: :pending)
50
51
  DataPorter::ImportJob.perform_later(@import.id)
51
52
  redirect_to import_path(@import)
52
53
  end
@@ -57,10 +58,22 @@ module DataPorter
57
58
  end
58
59
 
59
60
  def dry_run
61
+ @import.update!(status: :pending)
60
62
  DataPorter::DryRunJob.perform_later(@import.id)
61
63
  redirect_to import_path(@import)
62
64
  end
63
65
 
66
+ def status
67
+ progress = @import.config["progress"] || {}
68
+ render json: { status: @import.status, progress: progress }
69
+ end
70
+
71
+ def destroy
72
+ @import.file.purge if @import.file.attached?
73
+ @import.destroy!
74
+ redirect_to imports_path
75
+ end
76
+
64
77
  private
65
78
 
66
79
  def set_import
@@ -81,6 +94,15 @@ module DataPorter
81
94
  params.require(:data_import).permit(:target_key, :source_type, :file, config: {})
82
95
  end
83
96
 
97
+ def valid_source_for_target?
98
+ target = DataPorter::Registry.find(@import.target_key)
99
+ allowed = target._sources || DataPorter.configuration.enabled_sources
100
+ return true if allowed.map(&:to_s).include?(@import.source_type.to_s)
101
+
102
+ @import.errors.add(:source_type, "#{@import.source_type} is not available for this target")
103
+ false
104
+ end
105
+
84
106
  def valid_file_presence?
85
107
  return true unless %w[csv json xlsx].include?(@import.source_type)
86
108
  return true if @import.file.attached?
@@ -1,33 +1,42 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import { createConsumer } from "@rails/actioncable"
3
2
 
4
3
  export default class extends Controller {
5
- static targets = ["bar", "text"]
6
- static values = { id: Number }
4
+ static targets = ["bar", "text", "label"]
5
+ static values = { id: Number, url: String }
7
6
 
8
7
  connect() {
9
- this.subscription = createConsumer().subscriptions.create(
10
- { channel: "DataPorter::ImportChannel", id: this.idValue },
11
- {
12
- received: (data) => {
13
- if (data.status === "processing") {
14
- this.updateProgress(data.percentage)
15
- } else {
16
- Turbo.visit(window.location.href)
17
- }
18
- }
8
+ this.poll()
9
+ }
10
+
11
+ poll() {
12
+ this.timer = setInterval(() => this.fetchStatus(), 1000)
13
+ }
14
+
15
+ async fetchStatus() {
16
+ try {
17
+ const response = await fetch(this.urlValue)
18
+ const data = await response.json()
19
+ this.updateLabel(data.status)
20
+ if (data.progress && data.progress.percentage !== undefined) {
21
+ this.barTarget.style.width = data.progress.percentage + "%"
22
+ this.textTarget.textContent = data.progress.percentage + "%"
23
+ }
24
+ if (!["pending", "importing", "parsing", "dry_running", "extracting_headers"].includes(data.status)) {
25
+ clearInterval(this.timer)
26
+ this.barTarget.style.width = "100%"
27
+ this.textTarget.textContent = "100%"
28
+ setTimeout(() => Turbo.visit(window.location.href, { action: "replace" }), 500)
19
29
  }
20
- )
30
+ } catch (e) {}
21
31
  }
22
32
 
23
- updateProgress(percentage) {
24
- if (this.hasBarTarget) {
25
- this.barTarget.style.width = `${percentage}%`
26
- this.textTarget.textContent = `${percentage}%`
27
- }
33
+ updateLabel(status) {
34
+ if (!this.hasLabelTarget) return
35
+ var labels = { pending: "Waiting...", extracting_headers: "Extracting headers...", parsing: "Parsing records...", importing: "Importing...", dry_running: "Dry run..." }
36
+ this.labelTarget.textContent = labels[status] || "Processing..."
28
37
  }
29
38
 
30
39
  disconnect() {
31
- this.subscription?.unsubscribe()
40
+ if (this.timer) clearInterval(this.timer)
32
41
  }
33
42
  }
@@ -25,6 +25,11 @@ module DataPorter
25
25
 
26
26
  attribute :config, :json, default: -> { {} }
27
27
 
28
+ scope :purgeable, lambda {
29
+ where(status: %i[completed failed])
30
+ .where(created_at: ...DataPorter.configuration.purge_after.ago)
31
+ }
32
+
28
33
  validates :target_key, presence: true
29
34
  validates :source_type, presence: true, inclusion: { in: %w[csv json api xlsx] }
30
35
 
@@ -29,7 +29,14 @@
29
29
  <td><%= import.source_type %></td>
30
30
  <td><%= raw DataPorter::Components::Shared::StatusBadge.new(status: import.status).call %></td>
31
31
  <td><%= import.created_at&.strftime("%Y-%m-%d %H:%M") %></td>
32
- <td><%= link_to "View", import_path(import), class: "dp-link" %></td>
32
+ <td class="dp-table__actions">
33
+ <%= link_to "View", import_path(import), class: "dp-btn dp-btn--sm dp-btn--secondary" %>
34
+ <% if import.completed? || import.failed? %>
35
+ <%= button_to "Delete", import_path(import),
36
+ method: :delete, class: "dp-btn dp-btn--sm dp-btn--danger",
37
+ data: { turbo_confirm: "Delete this import?" } %>
38
+ <% end %>
39
+ </td>
33
40
  </tr>
34
41
  <% end %>
35
42
  </tbody>
@@ -59,7 +66,9 @@
59
66
  <%= f.select :target_key,
60
67
  @targets.map { |t| [t[:label], t[:key]] },
61
68
  { prompt: "Select a target..." },
62
- class: "dp-select" %>
69
+ id: "dp-target-select",
70
+ class: "dp-select",
71
+ data: { sources: @targets.map { |t| [t[:key], t[:sources]] }.to_h.to_json } %>
63
72
  </div>
64
73
 
65
74
  <div class="dp-field">
@@ -94,12 +103,31 @@
94
103
 
95
104
  <script>
96
105
  (function() {
106
+ var targetSelect = document.getElementById("dp-target-select");
97
107
  var sourceSelect = document.getElementById("dp-source-select");
98
108
  var fileField = document.getElementById("dp-file-field");
99
109
  var fileInput = document.getElementById("dp-file-input");
100
110
  var dropzone = document.getElementById("dp-dropzone");
101
111
  var fileName = document.getElementById("dp-file-name");
102
112
 
113
+ function filterSources() {
114
+ if (!targetSelect || !sourceSelect) return;
115
+ var sourcesMap = JSON.parse(targetSelect.dataset.sources || "{}");
116
+ var allowed = sourcesMap[targetSelect.value];
117
+ var options = sourceSelect.options;
118
+ for (var i = 1; i < options.length; i++) {
119
+ options[i].style.display = allowed && allowed.indexOf(options[i].value) === -1 ? "none" : "";
120
+ }
121
+ if (allowed && sourceSelect.selectedIndex > 0 && allowed.indexOf(sourceSelect.value) === -1) {
122
+ sourceSelect.selectedIndex = 0;
123
+ fileField.style.display = "";
124
+ }
125
+ }
126
+
127
+ if (targetSelect) {
128
+ targetSelect.addEventListener("change", filterSources);
129
+ }
130
+
103
131
  if (sourceSelect) {
104
132
  sourceSelect.addEventListener("change", function() {
105
133
  fileField.style.display = this.value === "api" ? "none" : "";
@@ -17,7 +17,9 @@
17
17
  <%= f.select :target_key,
18
18
  @targets.map { |t| [t[:label], t[:key]] },
19
19
  { prompt: "Select a target..." },
20
- class: "dp-select" %>
20
+ id: "dp-target-select-new",
21
+ class: "dp-select",
22
+ data: { sources: @targets.map { |t| [t[:key], t[:sources]] }.to_h.to_json } %>
21
23
  </div>
22
24
 
23
25
  <div class="dp-field">
@@ -51,12 +53,31 @@
51
53
 
52
54
  <script>
53
55
  (function() {
56
+ var targetSelect = document.getElementById("dp-target-select-new");
54
57
  var sourceSelect = document.getElementById("dp-source-select-new");
55
58
  var fileField = document.getElementById("dp-file-field-new");
56
59
  var fileInput = document.getElementById("dp-file-input-new");
57
60
  var dropzone = document.getElementById("dp-dropzone-new");
58
61
  var fileName = document.getElementById("dp-file-name-new");
59
62
 
63
+ function filterSources() {
64
+ if (!targetSelect || !sourceSelect) return;
65
+ var sourcesMap = JSON.parse(targetSelect.dataset.sources || "{}");
66
+ var allowed = sourcesMap[targetSelect.value];
67
+ var options = sourceSelect.options;
68
+ for (var i = 1; i < options.length; i++) {
69
+ options[i].style.display = allowed && allowed.indexOf(options[i].value) === -1 ? "none" : "";
70
+ }
71
+ if (allowed && sourceSelect.selectedIndex > 0 && allowed.indexOf(sourceSelect.value) === -1) {
72
+ sourceSelect.selectedIndex = 0;
73
+ fileField.style.display = "";
74
+ }
75
+ }
76
+
77
+ if (targetSelect) {
78
+ targetSelect.addEventListener("change", filterSources);
79
+ }
80
+
60
81
  if (sourceSelect) {
61
82
  sourceSelect.addEventListener("change", function() {
62
83
  fileField.style.display = this.value === "api" ? "none" : "";
@@ -28,8 +28,8 @@
28
28
  </dl>
29
29
  </div>
30
30
 
31
- <% if @import.parsing? || @import.importing? || @import.dry_running? || @import.extracting_headers? %>
32
- <%= raw DataPorter::Components::Progress::Bar.new(import_id: @import.id).call %>
31
+ <% if @import.pending? || @import.parsing? || @import.importing? || @import.dry_running? || @import.extracting_headers? %>
32
+ <%= raw DataPorter::Components::Progress::Bar.new(import_id: @import.id, status_url: status_import_path(@import)).call %>
33
33
  <% end %>
34
34
 
35
35
  <% if @import.mapping? %>
@@ -52,11 +52,13 @@
52
52
  ).call %>
53
53
 
54
54
  <div class="dp-actions">
55
- <%= button_to "Confirm", confirm_import_path(@import),
56
- method: :post, class: "dp-btn dp-btn--primary" %>
55
+ <%= button_to confirm_import_path(@import), method: :post, class: "dp-btn dp-btn--primary", data: { dp_submit: true } do %>
56
+ Confirm Import
57
+ <% end %>
57
58
  <% if @target._dry_run_enabled %>
58
- <%= button_to "Dry Run", dry_run_import_path(@import),
59
- method: :post, class: "dp-btn dp-btn--secondary" %>
59
+ <%= button_to dry_run_import_path(@import), method: :post, class: "dp-btn dp-btn--secondary", data: { dp_submit: true } do %>
60
+ Dry Run
61
+ <% end %>
60
62
  <% end %>
61
63
  <%= button_to "Cancel", cancel_import_path(@import),
62
64
  method: :post, class: "dp-btn dp-btn--danger" %>
@@ -64,7 +66,20 @@
64
66
  <% end %>
65
67
 
66
68
  <% if @import.completed? %>
67
- <%= raw DataPorter::Components::Preview::ResultsSummary.new(report: @import.report).call %>
69
+ <% duration = @import.updated_at && @import.created_at ? distance_of_time_in_words(@import.created_at, @import.updated_at) : nil %>
70
+ <%= raw DataPorter::Components::Preview::ResultsSummary.new(report: @import.report, duration: duration).call %>
71
+ <% if @records.any? %>
72
+ <%= raw DataPorter::Components::Preview::Table.new(
73
+ columns: @target._columns,
74
+ records: @records
75
+ ).call %>
76
+ <% end %>
77
+ <div class="dp-actions">
78
+ <%= link_to "Back to imports", imports_path, class: "dp-btn dp-btn--primary" %>
79
+ <%= button_to "Delete", import_path(@import),
80
+ method: :delete, class: "dp-btn dp-btn--danger",
81
+ data: { turbo_confirm: "Delete this import?" } %>
82
+ </div>
68
83
  <% end %>
69
84
 
70
85
  <% if @import.failed? %>
@@ -72,6 +87,20 @@
72
87
  <div class="dp-actions">
73
88
  <%= button_to "Retry", parse_import_path(@import),
74
89
  method: :post, class: "dp-btn dp-btn--primary" %>
90
+ <%= button_to "Delete", import_path(@import),
91
+ method: :delete, class: "dp-btn dp-btn--danger",
92
+ data: { turbo_confirm: "Delete this import?" } %>
75
93
  </div>
76
94
  <% end %>
77
95
  </div>
96
+
97
+ <script>
98
+ (function() {
99
+ document.querySelectorAll("[data-dp-submit]").forEach(function(btn) {
100
+ btn.closest("form").addEventListener("submit", function() {
101
+ btn.disabled = true;
102
+ btn.innerHTML = '<span class="dp-spinner"></span>Processing...';
103
+ });
104
+ });
105
+ })();
106
+ </script>
@@ -124,36 +124,42 @@
124
124
  })
125
125
 
126
126
  application.register("data-porter--progress", class extends Controller {
127
- static targets = ["bar", "text"]
128
- static values = { id: Number }
127
+ static targets = ["bar", "text", "label"]
128
+ static values = { id: Number, url: String }
129
129
 
130
- async connect() {
130
+ connect() {
131
+ this.poll()
132
+ }
133
+
134
+ poll() {
135
+ this.timer = setInterval(() => this.fetchStatus(), 1000)
136
+ }
137
+
138
+ async fetchStatus() {
131
139
  try {
132
- const { createConsumer } = await import("@rails/actioncable")
133
- this.subscription = createConsumer().subscriptions.create(
134
- { channel: "DataPorter::ImportChannel", id: this.idValue },
135
- {
136
- received: (data) => {
137
- if (data.status === "processing") {
138
- this.barTarget.style.width = data.percentage + "%"
139
- this.textTarget.textContent = data.percentage + "%"
140
- } else {
141
- Turbo.visit(window.location.href)
142
- }
143
- }
144
- }
145
- )
146
- } catch (e) {
147
- this.pollForChanges()
148
- }
140
+ const response = await fetch(this.urlValue)
141
+ const data = await response.json()
142
+ this.updateLabel(data.status)
143
+ if (data.progress && data.progress.percentage !== undefined) {
144
+ this.barTarget.style.width = data.progress.percentage + "%"
145
+ this.textTarget.textContent = data.progress.percentage + "%"
146
+ }
147
+ if (!["pending", "importing", "parsing", "dry_running", "extracting_headers"].includes(data.status)) {
148
+ clearInterval(this.timer)
149
+ this.barTarget.style.width = "100%"
150
+ this.textTarget.textContent = "100%"
151
+ setTimeout(() => Turbo.visit(window.location.href, { action: "replace" }), 500)
152
+ }
153
+ } catch (e) {}
149
154
  }
150
155
 
151
- pollForChanges() {
152
- this.timer = setInterval(() => Turbo.visit(window.location.href), 3000)
156
+ updateLabel(status) {
157
+ if (!this.hasLabelTarget) return
158
+ var labels = { pending: "Waiting...", extracting_headers: "Extracting headers...", parsing: "Parsing records...", importing: "Importing...", dry_running: "Dry run..." }
159
+ this.labelTarget.textContent = labels[status] || "Processing..."
153
160
  }
154
161
 
155
162
  disconnect() {
156
- if (this.subscription) this.subscription.unsubscribe()
157
163
  if (this.timer) clearInterval(this.timer)
158
164
  }
159
165
  })
data/config/routes.rb CHANGED
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  DataPorter::Engine.routes.draw do
4
- resources :imports, only: %i[index new create show] do
4
+ resources :imports, only: %i[index new create show destroy] do
5
5
  member do
6
6
  post :parse
7
7
  post :confirm
8
8
  post :cancel
9
9
  post :dry_run
10
10
  patch :update_mapping
11
+ get :status
11
12
  end
12
13
  end
13
14
 
@@ -4,17 +4,56 @@ module DataPorter
4
4
  module Components
5
5
  module Preview
6
6
  class ResultsSummary < Base
7
- def initialize(report:)
7
+ def initialize(report:, duration: nil)
8
8
  super()
9
9
  @report = report
10
+ @duration = duration
10
11
  end
11
12
 
12
13
  def view_template
13
- div(class: "dp-results") do
14
- p { "Created: #{@report.imported_count}" }
15
- p { "Errors: #{@report.errored_count}" }
14
+ div(class: "dp-results #{result_class}") do
15
+ render_icon
16
+ render_title
17
+ render_stats
18
+ render_duration if @duration
16
19
  end
17
20
  end
21
+
22
+ private
23
+
24
+ def render_icon
25
+ div(class: "dp-results__icon") { success? ? "\u2714" : "\u26A0" }
26
+ end
27
+
28
+ def render_title
29
+ h3(class: "dp-results__title") { success? ? "Import completed" : "Import completed with errors" }
30
+ end
31
+
32
+ def render_stats
33
+ div(class: "dp-results__cards") do
34
+ stat("dp-results__stat--success", @report.imported_count, "Imported")
35
+ stat("dp-results__stat--error", @report.errored_count, "Errors")
36
+ end
37
+ end
38
+
39
+ def render_duration
40
+ div(class: "dp-results__duration") { "Duration: #{@duration}" }
41
+ end
42
+
43
+ def stat(css_class, count, label)
44
+ div(class: "dp-results__stat #{css_class}") do
45
+ strong { count.to_s }
46
+ span { label }
47
+ end
48
+ end
49
+
50
+ def result_class
51
+ success? ? "dp-results--success" : "dp-results--partial"
52
+ end
53
+
54
+ def success?
55
+ @report.errored_count.zero?
56
+ end
18
57
  end
19
58
  end
20
59
  end
@@ -4,30 +4,42 @@ module DataPorter
4
4
  module Components
5
5
  module Progress
6
6
  class Bar < Base
7
- def initialize(import_id:)
7
+ def initialize(import_id:, status_url: nil)
8
8
  super()
9
9
  @import_id = import_id
10
+ @status_url = status_url
10
11
  end
11
12
 
12
13
  def view_template
13
- div(class: "dp-progress", **stimulus_controller_attrs) do
14
+ div(class: "dp-progress-container", **stimulus_attrs) do
15
+ render_label
14
16
  render_bar
15
17
  end
16
18
  end
17
19
 
18
20
  private
19
21
 
22
+ def render_label
23
+ div(class: "dp-progress-label") do
24
+ span(data_data_porter__progress_target: "label") { "Waiting..." }
25
+ end
26
+ end
27
+
20
28
  def render_bar
21
- div(class: "dp-progress-bar", data_data_porter__progress_target: "bar", style: "width: 0%") do
22
- span(data_data_porter__progress_target: "text") { "0%" }
29
+ div(class: "dp-progress") do
30
+ div(class: "dp-progress-bar", data_data_porter__progress_target: "bar", style: "width: 0%") do
31
+ span(data_data_porter__progress_target: "text") { "0%" }
32
+ end
23
33
  end
24
34
  end
25
35
 
26
- def stimulus_controller_attrs
27
- {
36
+ def stimulus_attrs
37
+ attrs = {
28
38
  data_controller: "data-porter--progress",
29
39
  data_data_porter__progress_id_value: @import_id.to_s
30
40
  }
41
+ attrs[:data_data_porter__progress_url_value] = @status_url if @status_url
42
+ attrs
31
43
  end
32
44
  end
33
45
  end
@@ -9,7 +9,8 @@ module DataPorter
9
9
  :context_builder,
10
10
  :preview_limit,
11
11
  :enabled_sources,
12
- :scope
12
+ :scope,
13
+ :purge_after
13
14
 
14
15
  def initialize
15
16
  @parent_controller = "ApplicationController"
@@ -20,6 +21,7 @@ module DataPorter
20
21
  @preview_limit = 500
21
22
  @enabled_sources = %i[csv json api xlsx]
22
23
  @scope = nil
24
+ @purge_after = 60.days
23
25
  end
24
26
  end
25
27
  end
@@ -5,6 +5,7 @@ module DataPorter
5
5
  def initialize(data_import, content: nil)
6
6
  @data_import = data_import
7
7
  @target = data_import.target_class.new
8
+ @broadcaster = Broadcaster.new(data_import.id)
8
9
  @source_options = { content: content }.compact
9
10
  end
10
11
 
@@ -111,12 +112,15 @@ module DataPorter
111
112
  importable = @data_import.importable_records
112
113
  context = build_context
113
114
  results = { created: 0, errored: 0 }
115
+ total = importable.size
114
116
 
115
- importable.each do |record|
117
+ importable.each_with_index do |record, index|
116
118
  persist_record(record, context, results)
119
+ broadcast_progress(index + 1, total)
117
120
  end
118
121
 
119
122
  @data_import.update!(status: :completed)
123
+ @broadcaster.success
120
124
  results
121
125
  end
122
126
 
@@ -151,11 +155,20 @@ module DataPorter
151
155
  DataPorter.configuration.context_builder&.call(@data_import)
152
156
  end
153
157
 
158
+ def broadcast_progress(current, total)
159
+ percentage = ((current.to_f / total) * 100).round
160
+ config = @data_import.config || {}
161
+ config["progress"] = { "current" => current, "total" => total, "percentage" => percentage }
162
+ @data_import.update_column(:config, config)
163
+ @broadcaster.progress(current, total)
164
+ end
165
+
154
166
  def handle_failure(error)
155
167
  report = StoreModels::Report.new(
156
168
  error_reports: [StoreModels::Error.new(message: error.message)]
157
169
  )
158
170
  @data_import.update!(status: :failed, report: report)
171
+ @broadcaster.failure(error.message)
159
172
  end
160
173
  end
161
174
  end
@@ -17,7 +17,12 @@ module DataPorter
17
17
 
18
18
  def available
19
19
  @targets.map do |key, klass|
20
- { key: key, label: klass._label, icon: klass._icon }
20
+ {
21
+ key: key,
22
+ label: klass._label,
23
+ icon: klass._icon,
24
+ sources: klass._sources || DataPorter.configuration.enabled_sources
25
+ }
21
26
  end
22
27
  end
23
28
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DataPorter
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -27,4 +27,8 @@ DataPorter.configure do |config|
27
27
 
28
28
  # Enabled source types.
29
29
  # config.enabled_sources = %i[csv json xlsx api]
30
+
31
+ # Auto-purge completed/failed imports older than this duration.
32
+ # Set to nil to disable auto-purge. Run `rake data_porter:purge` manually or via cron.
33
+ # config.purge_after = 60.days
30
34
  end
@@ -8,6 +8,7 @@ module DataPorter
8
8
  source_root File.expand_path("templates", __dir__)
9
9
 
10
10
  argument :columns, type: :array, default: [], banner: "name:type[:required]"
11
+ class_option :sources, type: :array, default: %w[csv], desc: "Enabled source types"
11
12
 
12
13
  def create_target_file
13
14
  template("target.rb.tt", "app/importers/#{file_name}_target.rb")
@@ -39,6 +40,10 @@ module DataPorter
39
40
  required: parts[2] == "required"
40
41
  }
41
42
  end
43
+
44
+ def target_sources
45
+ options[:sources].map { |s| ":#{s}" }.join(", ")
46
+ end
42
47
  end
43
48
  end
44
49
  end
@@ -4,7 +4,7 @@ class <%= target_class_name %> < DataPorter::Target
4
4
  label "<%= target_label %>"
5
5
  model_name "<%= model_name %>"
6
6
  icon "fas fa-file-import"
7
- sources :csv
7
+ sources <%= target_sources %>
8
8
  <% if parsed_columns.any? %>
9
9
 
10
10
  columns do
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :data_porter do
4
+ desc "Purge old completed/failed imports (default: 60 days, configure via purge_after)"
5
+ task purge: :environment do
6
+ count = DataPorter::DataImport.purgeable.destroy_all.count
7
+ puts "DataPorter: purged #{count} old import(s)"
8
+ end
9
+ 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: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seryl Lounis
@@ -182,6 +182,7 @@ files:
182
182
  - lib/generators/data_porter/install/templates/initializer.rb
183
183
  - lib/generators/data_porter/target/target_generator.rb
184
184
  - lib/generators/data_porter/target/templates/target.rb.tt
185
+ - lib/tasks/data_porter.rake
185
186
  - sig/data_porter.rbs
186
187
  homepage: https://github.com/SerylLns/data_porter
187
188
  licenses: