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 +4 -4
- data/CHANGELOG.md +24 -0
- data/app/assets/stylesheets/data_porter/alerts.css +62 -4
- data/app/assets/stylesheets/data_porter/layout.css +19 -0
- data/app/assets/stylesheets/data_porter/progress.css +37 -12
- data/app/assets/stylesheets/data_porter/table.css +6 -0
- data/app/controllers/data_porter/imports_controller.rb +24 -2
- data/app/javascript/data_porter/progress_controller.js +29 -20
- data/app/models/data_porter/data_import.rb +5 -0
- data/app/views/data_porter/imports/index.html.erb +30 -2
- data/app/views/data_porter/imports/new.html.erb +22 -1
- data/app/views/data_porter/imports/show.html.erb +36 -7
- data/app/views/layouts/data_porter/application.html.erb +29 -23
- data/config/routes.rb +2 -1
- data/lib/data_porter/components/preview/results_summary.rb +43 -4
- data/lib/data_porter/components/progress/bar.rb +18 -6
- data/lib/data_porter/configuration.rb +3 -1
- data/lib/data_porter/orchestrator.rb +14 -1
- data/lib/data_porter/registry.rb +6 -1
- data/lib/data_porter/version.rb +1 -1
- data/lib/generators/data_porter/install/templates/initializer.rb +4 -0
- data/lib/generators/data_porter/target/target_generator.rb +5 -0
- data/lib/generators/data_porter/target/templates/target.rb.tt +1 -1
- data/lib/tasks/data_porter.rake +9 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e99d5974ba60bb61902d1dcf98ad74d940fd82344ec88404fa55e2414cee3fc0
|
|
4
|
+
data.tar.gz: 7166547614a1a5942648e85d900d30abe053b4ba4f066811bb71389b83359531
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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-
|
|
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
|
-
|
|
4
|
-
|
|
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.
|
|
7
|
-
|
|
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(
|
|
13
|
-
|
|
14
|
-
|
|
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.
|
|
39
|
+
font-size: 0.6875rem;
|
|
20
40
|
font-weight: 700;
|
|
21
|
-
min-width: 2.
|
|
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.
|
|
31
|
-
animation: dp-shimmer
|
|
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 {
|
|
@@ -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.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
24
|
-
if (this.
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
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
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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", **
|
|
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
|
|
22
|
-
|
|
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
|
|
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.
|
|
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
|
data/lib/data_porter/registry.rb
CHANGED
|
@@ -17,7 +17,12 @@ module DataPorter
|
|
|
17
17
|
|
|
18
18
|
def available
|
|
19
19
|
@targets.map do |key, klass|
|
|
20
|
-
{
|
|
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
|
|
data/lib/data_porter/version.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
+
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:
|