data_porter 0.4.0 → 0.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +12 -6
  4. data/app/assets/javascripts/data_porter/import_form_controller.js +126 -0
  5. data/app/assets/javascripts/data_porter/progress_controller.js +42 -0
  6. data/app/assets/stylesheets/data_porter/alerts.css +62 -4
  7. data/app/assets/stylesheets/data_porter/layout.css +19 -0
  8. data/app/assets/stylesheets/data_porter/progress.css +37 -12
  9. data/app/assets/stylesheets/data_porter/table.css +51 -0
  10. data/app/controllers/data_porter/concerns/import_validation.rb +47 -0
  11. data/app/controllers/data_porter/concerns/mapping_management.rb +43 -0
  12. data/app/controllers/data_porter/concerns/record_pagination.rb +19 -0
  13. data/app/controllers/data_porter/imports_controller.rb +28 -40
  14. data/app/models/data_porter/data_import.rb +5 -0
  15. data/app/views/data_porter/imports/index.html.erb +67 -95
  16. data/app/views/data_porter/imports/new.html.erb +71 -1
  17. data/app/views/data_porter/imports/show.html.erb +46 -7
  18. data/app/views/layouts/data_porter/application.html.erb +17 -140
  19. data/config/routes.rb +2 -1
  20. data/docs/CONFIGURATION.md +28 -6
  21. data/docs/ROADMAP.md +28 -0
  22. data/docs/TARGETS.md +54 -3
  23. data/lib/data_porter/components/preview/results_summary.rb +43 -4
  24. data/lib/data_porter/components/progress/bar.rb +18 -6
  25. data/lib/data_porter/components/shared/pagination.rb +53 -0
  26. data/lib/data_porter/components.rb +1 -0
  27. data/lib/data_porter/configuration.rb +3 -1
  28. data/lib/data_porter/dsl/param.rb +32 -0
  29. data/lib/data_porter/engine.rb +4 -0
  30. data/lib/data_porter/orchestrator/dry_runner.rb +30 -0
  31. data/lib/data_porter/orchestrator/importer.rb +41 -0
  32. data/lib/data_porter/orchestrator/record_builder.rb +38 -0
  33. data/lib/data_porter/orchestrator.rb +26 -80
  34. data/lib/data_porter/registry.rb +26 -1
  35. data/lib/data_porter/target.rb +17 -1
  36. data/lib/data_porter/version.rb +1 -1
  37. data/lib/generators/data_porter/install/templates/initializer.rb +4 -0
  38. data/lib/generators/data_porter/target/target_generator.rb +5 -0
  39. data/lib/generators/data_porter/target/templates/target.rb.tt +1 -1
  40. data/lib/tasks/data_porter.rake +9 -0
  41. metadata +15 -4
  42. data/app/javascript/data_porter/progress_controller.js +0 -33
  43. /data/app/{javascript → assets/javascripts}/data_porter/mapping_controller.js +0 -0
  44. /data/app/{javascript → assets/javascripts}/data_porter/template_form_controller.js +0 -0
@@ -2,9 +2,13 @@
2
2
 
3
3
  module DataPorter
4
4
  class ImportsController < DataPorter.configuration.parent_controller.constantize
5
+ include Concerns::ImportValidation
6
+ include Concerns::MappingManagement
7
+ include Concerns::RecordPagination
8
+
5
9
  layout "data_porter/application"
6
10
 
7
- before_action :set_import, only: %i[show parse confirm cancel dry_run update_mapping]
11
+ before_action :set_import, only: %i[show parse confirm cancel dry_run update_mapping status destroy]
8
12
  before_action :load_targets, only: %i[index new create]
9
13
 
10
14
  def index
@@ -18,7 +22,7 @@ module DataPorter
18
22
  def create
19
23
  build_import
20
24
 
21
- if valid_file_presence? && @import.save
25
+ if valid_source_for_target? && valid_file_presence? && valid_import_params? && @import.save
22
26
  enqueue_after_create
23
27
  redirect_to import_path(@import)
24
28
  else
@@ -30,6 +34,7 @@ module DataPorter
30
34
  @target = @import.target_class
31
35
  @records = @import.records
32
36
  @grouped = @records.group_by(&:status)
37
+ paginate_records
33
38
  load_mapping_data if @import.mapping?
34
39
  end
35
40
 
@@ -47,6 +52,7 @@ module DataPorter
47
52
  end
48
53
 
49
54
  def confirm
55
+ @import.update!(status: :pending)
50
56
  DataPorter::ImportJob.perform_later(@import.id)
51
57
  redirect_to import_path(@import)
52
58
  end
@@ -57,10 +63,22 @@ module DataPorter
57
63
  end
58
64
 
59
65
  def dry_run
66
+ @import.update!(status: :pending)
60
67
  DataPorter::DryRunJob.perform_later(@import.id)
61
68
  redirect_to import_path(@import)
62
69
  end
63
70
 
71
+ def status
72
+ progress = @import.config["progress"] || {}
73
+ render json: { status: @import.status, progress: progress }
74
+ end
75
+
76
+ def destroy
77
+ @import.file.purge if @import.file.attached?
78
+ @import.destroy!
79
+ redirect_to imports_path
80
+ end
81
+
64
82
  private
65
83
 
66
84
  def set_import
@@ -78,15 +96,17 @@ module DataPorter
78
96
  end
79
97
 
80
98
  def import_params
81
- params.require(:data_import).permit(:target_key, :source_type, :file, config: {})
99
+ permitted = params.require(:data_import).permit(:target_key, :source_type, :file, config: {})
100
+ merge_import_params(permitted)
82
101
  end
83
102
 
84
- def valid_file_presence?
85
- return true unless %w[csv json xlsx].include?(@import.source_type)
86
- return true if @import.file.attached?
103
+ def merge_import_params(permitted)
104
+ nested = params.dig(:data_import, :config, :import_params)
105
+ return permitted unless nested
87
106
 
88
- @import.errors.add(:file, "must be attached for #{@import.source_type.upcase} imports")
89
- false
107
+ config = permitted[:config] || {}
108
+ config["import_params"] = nested.permit!.to_h
109
+ permitted.merge(config: config)
90
110
  end
91
111
 
92
112
  def enqueue_after_create
@@ -96,37 +116,5 @@ module DataPorter
96
116
  DataPorter::ParseJob.perform_later(@import.id)
97
117
  end
98
118
  end
99
-
100
- def load_mapping_data
101
- target = @import.target_class
102
- columns = target._columns || []
103
- @file_headers = @import.config["file_headers"] || []
104
- @target_columns = columns.map { |c| [c.label, c.name.to_s, c.required] }
105
- @default_mapping = (target._csv_mappings || {}).transform_values(&:to_s)
106
- @templates = load_templates
107
- end
108
-
109
- def load_templates
110
- return [] unless defined?(DataPorter::MappingTemplate)
111
-
112
- DataPorter::MappingTemplate.for_target(@import.target_key)
113
- end
114
-
115
- def save_column_mapping
116
- mapping = params.require(:column_mapping).permit!.to_h
117
- merged = (@import.config || {}).merge("column_mapping" => mapping)
118
- @import.update!(config: merged, status: :pending)
119
- end
120
-
121
- def save_template_if_requested
122
- return unless params[:save_template] == "1"
123
- return unless defined?(DataPorter::MappingTemplate)
124
-
125
- mapping = params.require(:column_mapping).permit!.to_h
126
- DataPorter::MappingTemplate.find_or_initialize_by(
127
- target_key: @import.target_key,
128
- name: params[:template_name].presence || "Default"
129
- ).update!(mapping: mapping)
130
- end
131
119
  end
132
120
  end
@@ -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
 
@@ -1,9 +1,12 @@
1
- <div class="data-porter">
1
+ <div class="data-porter" data-controller="data-porter--import-form"
2
+ data-data-porter--import-form-sources-value="<%= @targets.map { |t| [t[:key], t[:sources]] }.to_h.to_json %>"
3
+ data-data-porter--import-form-params-value="<%= @targets.map { |t| [t[:key], t[:params]] }.to_h.to_json %>"
4
+ data-action="keydown@document->data-porter--import-form#closeModal">
2
5
  <div class="dp-header">
3
6
  <h1 class="dp-title">Imports</h1>
4
7
  <div class="dp-header__actions">
5
8
  <%= link_to "Mapping Templates", mapping_templates_path, class: "dp-btn dp-btn--secondary" %>
6
- <button type="button" class="dp-btn dp-btn--primary" onclick="document.getElementById('dp-modal').classList.add('dp-modal--open')">
9
+ <button type="button" class="dp-btn dp-btn--primary" data-action="data-porter--import-form#openModal">
7
10
  New Import
8
11
  </button>
9
12
  </div>
@@ -29,7 +32,14 @@
29
32
  <td><%= import.source_type %></td>
30
33
  <td><%= raw DataPorter::Components::Shared::StatusBadge.new(status: import.status).call %></td>
31
34
  <td><%= import.created_at&.strftime("%Y-%m-%d %H:%M") %></td>
32
- <td><%= link_to "View", import_path(import), class: "dp-link" %></td>
35
+ <td class="dp-table__actions">
36
+ <%= link_to "View", import_path(import), class: "dp-btn dp-btn--sm dp-btn--secondary" %>
37
+ <% if import.completed? || import.failed? %>
38
+ <%= button_to "Delete", import_path(import),
39
+ method: :delete, class: "dp-btn dp-btn--sm dp-btn--danger",
40
+ data: { turbo_confirm: "Delete this import?" } %>
41
+ <% end %>
42
+ </td>
33
43
  </tr>
34
44
  <% end %>
35
45
  </tbody>
@@ -38,106 +48,68 @@
38
48
  <div class="dp-empty-state">
39
49
  <div class="dp-empty-state__icon">&#128230;</div>
40
50
  <p class="dp-empty-state__text">No imports yet</p>
41
- <button type="button" class="dp-btn dp-btn--primary" onclick="document.getElementById('dp-modal').classList.add('dp-modal--open')">
51
+ <button type="button" class="dp-btn dp-btn--primary" data-action="data-porter--import-form#openModal">
42
52
  Create your first import
43
53
  </button>
44
54
  </div>
45
55
  <% end %>
46
- </div>
47
-
48
- <div id="dp-modal" class="dp-modal">
49
- <div class="dp-modal__backdrop" onclick="document.getElementById('dp-modal').classList.remove('dp-modal--open')"></div>
50
- <div class="dp-modal__content">
51
- <div class="dp-modal__header">
52
- <h2 class="dp-modal__title">New Import</h2>
53
- <button type="button" class="dp-modal__close" onclick="document.getElementById('dp-modal').classList.remove('dp-modal--open')">&times;</button>
54
- </div>
55
-
56
- <%= form_with model: DataPorter::DataImport.new, url: imports_path, class: "dp-modal__body", multipart: true, data: { turbo: false } do |f| %>
57
- <div class="dp-field">
58
- <%= f.label :target_key, "Target", class: "dp-label" %>
59
- <%= f.select :target_key,
60
- @targets.map { |t| [t[:label], t[:key]] },
61
- { prompt: "Select a target..." },
62
- class: "dp-select" %>
63
- </div>
64
-
65
- <div class="dp-field">
66
- <%= f.label :source_type, "Source Type", class: "dp-label" %>
67
- <%= f.select :source_type,
68
- DataPorter.configuration.enabled_sources.map { |s| [s.to_s.upcase, s] },
69
- { prompt: "Select source type..." },
70
- id: "dp-source-select",
71
- class: "dp-select" %>
72
- </div>
73
56
 
74
- <div id="dp-file-field" class="dp-field">
75
- <%= f.label :file, "File", class: "dp-label" %>
76
- <label class="dp-dropzone" id="dp-dropzone">
77
- <input type="file" name="data_import[file]" id="dp-file-input" class="dp-dropzone__input" />
78
- <div class="dp-dropzone__content">
79
- <div class="dp-dropzone__icon">&#128196;</div>
80
- <span class="dp-dropzone__text">Drop your file here or <strong>browse</strong></span>
81
- <span class="dp-dropzone__hint">CSV, JSON, or XLSX files accepted</span>
82
- </div>
83
- <div class="dp-dropzone__selected" id="dp-file-name" style="display: none;"></div>
84
- </label>
57
+ <div id="dp-modal" class="dp-modal" data-data-porter--import-form-target="modal">
58
+ <div class="dp-modal__backdrop" data-action="click->data-porter--import-form#closeModalClick"></div>
59
+ <div class="dp-modal__content">
60
+ <div class="dp-modal__header">
61
+ <h2 class="dp-modal__title">New Import</h2>
62
+ <button type="button" class="dp-modal__close" data-action="data-porter--import-form#closeModalClick">&times;</button>
85
63
  </div>
86
64
 
87
- <div class="dp-modal__footer">
88
- <%= f.submit "Start Import", class: "dp-btn dp-btn--primary" %>
89
- <button type="button" class="dp-btn dp-btn--secondary" onclick="document.getElementById('dp-modal').classList.remove('dp-modal--open')">Cancel</button>
90
- </div>
91
- <% end %>
92
- </div>
93
- </div>
94
-
95
- <script>
96
- (function() {
97
- var sourceSelect = document.getElementById("dp-source-select");
98
- var fileField = document.getElementById("dp-file-field");
99
- var fileInput = document.getElementById("dp-file-input");
100
- var dropzone = document.getElementById("dp-dropzone");
101
- var fileName = document.getElementById("dp-file-name");
65
+ <%= form_with model: DataPorter::DataImport.new, url: imports_path, class: "dp-modal__body", multipart: true, data: { turbo: false } do |f| %>
66
+ <div class="dp-field">
67
+ <%= f.label :target_key, "Target", class: "dp-label" %>
68
+ <%= f.select :target_key,
69
+ @targets.map { |t| [t[:label], t[:key]] },
70
+ { prompt: "Select a target..." },
71
+ class: "dp-select",
72
+ data: {
73
+ data_porter__import_form_target: "targetSelect",
74
+ action: "data-porter--import-form#filterSources"
75
+ } %>
76
+ </div>
102
77
 
103
- if (sourceSelect) {
104
- sourceSelect.addEventListener("change", function() {
105
- fileField.style.display = this.value === "api" ? "none" : "";
106
- });
107
- }
78
+ <div data-data-porter--import-form-target="paramsContainer"></div>
108
79
 
109
- if (fileInput) {
110
- fileInput.addEventListener("change", function() {
111
- if (this.files.length > 0) {
112
- fileName.textContent = this.files[0].name;
113
- fileName.style.display = "";
114
- dropzone.classList.add("dp-dropzone--has-file");
115
- }
116
- });
117
- }
80
+ <div class="dp-field">
81
+ <%= f.label :source_type, "Source Type", class: "dp-label" %>
82
+ <%= f.select :source_type,
83
+ DataPorter.configuration.enabled_sources.map { |s| [s.to_s.upcase, s] },
84
+ { prompt: "Select source type..." },
85
+ class: "dp-select",
86
+ data: {
87
+ data_porter__import_form_target: "sourceSelect",
88
+ action: "data-porter--import-form#toggleFileField"
89
+ } %>
90
+ </div>
118
91
 
119
- if (dropzone) {
120
- dropzone.addEventListener("dragover", function(e) {
121
- e.preventDefault();
122
- this.classList.add("dp-dropzone--dragover");
123
- });
124
- dropzone.addEventListener("dragleave", function() {
125
- this.classList.remove("dp-dropzone--dragover");
126
- });
127
- dropzone.addEventListener("drop", function(e) {
128
- e.preventDefault();
129
- this.classList.remove("dp-dropzone--dragover");
130
- if (e.dataTransfer.files.length > 0) {
131
- fileInput.files = e.dataTransfer.files;
132
- fileInput.dispatchEvent(new Event("change"));
133
- }
134
- });
135
- }
92
+ <div class="dp-field" data-data-porter--import-form-target="fileField">
93
+ <%= f.label :file, "File", class: "dp-label" %>
94
+ <label class="dp-dropzone" data-data-porter--import-form-target="dropzone"
95
+ data-action="dragover->data-porter--import-form#dragover dragleave->data-porter--import-form#dragleave drop->data-porter--import-form#drop">
96
+ <input type="file" name="data_import[file]" class="dp-dropzone__input"
97
+ data-data-porter--import-form-target="fileInput"
98
+ data-action="data-porter--import-form#handleFile" />
99
+ <div class="dp-dropzone__content">
100
+ <div class="dp-dropzone__icon">&#128196;</div>
101
+ <span class="dp-dropzone__text">Drop your file here or <strong>browse</strong></span>
102
+ <span class="dp-dropzone__hint">CSV, JSON, or XLSX files accepted</span>
103
+ </div>
104
+ <div class="dp-dropzone__selected" data-data-porter--import-form-target="fileName" style="display: none;"></div>
105
+ </label>
106
+ </div>
136
107
 
137
- document.addEventListener("keydown", function(e) {
138
- if (e.key === "Escape") {
139
- document.getElementById("dp-modal").classList.remove("dp-modal--open");
140
- }
141
- });
142
- })();
143
- </script>
108
+ <div class="dp-modal__footer">
109
+ <%= f.submit "Start Import", class: "dp-btn dp-btn--primary" %>
110
+ <button type="button" class="dp-btn dp-btn--secondary" data-action="data-porter--import-form#closeModalClick">Cancel</button>
111
+ </div>
112
+ <% end %>
113
+ </div>
114
+ </div>
115
+ </div>
@@ -17,9 +17,16 @@
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: {
23
+ sources: @targets.map { |t| [t[:key], t[:sources]] }.to_h.to_json,
24
+ params: @targets.map { |t| [t[:key], t[:params]] }.to_h.to_json
25
+ } %>
21
26
  </div>
22
27
 
28
+ <div id="dp-params-container" class="dp-params-container"></div>
29
+
23
30
  <div class="dp-field">
24
31
  <%= f.label :source_type, "Source Type", class: "dp-label" %>
25
32
  <%= f.select :source_type,
@@ -51,11 +58,74 @@
51
58
 
52
59
  <script>
53
60
  (function() {
61
+ var targetSelect = document.getElementById("dp-target-select-new");
54
62
  var sourceSelect = document.getElementById("dp-source-select-new");
55
63
  var fileField = document.getElementById("dp-file-field-new");
56
64
  var fileInput = document.getElementById("dp-file-input-new");
57
65
  var dropzone = document.getElementById("dp-dropzone-new");
58
66
  var fileName = document.getElementById("dp-file-name-new");
67
+ var paramsContainer = document.getElementById("dp-params-container");
68
+ var paramsData = JSON.parse((targetSelect && targetSelect.dataset.params) || "{}");
69
+
70
+ function renderParams() {
71
+ if (!paramsContainer || !targetSelect) return;
72
+ paramsContainer.innerHTML = "";
73
+ var defs = paramsData[targetSelect.value] || [];
74
+ defs.forEach(function(p) {
75
+ var div = document.createElement("div");
76
+ div.className = "dp-field";
77
+ var label = document.createElement("label");
78
+ label.className = "dp-label";
79
+ label.textContent = p.label + (p.required ? " *" : "");
80
+ div.appendChild(label);
81
+ var input;
82
+ if (p.type === "select" && p.collection) {
83
+ input = document.createElement("select");
84
+ input.className = "dp-select";
85
+ var blank = document.createElement("option");
86
+ blank.value = "";
87
+ blank.textContent = "Select...";
88
+ input.appendChild(blank);
89
+ p.collection.forEach(function(opt) {
90
+ var o = document.createElement("option");
91
+ o.textContent = opt[0];
92
+ o.value = opt[1];
93
+ if (p["default"] && String(opt[1]) === String(p["default"])) o.selected = true;
94
+ input.appendChild(o);
95
+ });
96
+ } else {
97
+ input = document.createElement("input");
98
+ input.className = "dp-input";
99
+ input.type = p.type === "number" ? "number" : (p.type === "hidden" ? "hidden" : "text");
100
+ if (p["default"]) input.value = p["default"];
101
+ }
102
+ input.name = "data_import[config][import_params][" + p.name + "]";
103
+ if (p.required) input.required = true;
104
+ div.appendChild(input);
105
+ paramsContainer.appendChild(div);
106
+ });
107
+ }
108
+
109
+ function filterSources() {
110
+ if (!targetSelect || !sourceSelect) return;
111
+ var sourcesMap = JSON.parse(targetSelect.dataset.sources || "{}");
112
+ var allowed = sourcesMap[targetSelect.value];
113
+ var options = sourceSelect.options;
114
+ for (var i = 1; i < options.length; i++) {
115
+ options[i].style.display = allowed && allowed.indexOf(options[i].value) === -1 ? "none" : "";
116
+ }
117
+ if (allowed && sourceSelect.selectedIndex > 0 && allowed.indexOf(sourceSelect.value) === -1) {
118
+ sourceSelect.selectedIndex = 0;
119
+ fileField.style.display = "";
120
+ }
121
+ }
122
+
123
+ if (targetSelect) {
124
+ targetSelect.addEventListener("change", function() {
125
+ filterSources();
126
+ renderParams();
127
+ });
128
+ }
59
129
 
60
130
  if (sourceSelect) {
61
131
  sourceSelect.addEventListener("change", function() {
@@ -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? %>
@@ -45,18 +45,25 @@
45
45
  <% end %>
46
46
 
47
47
  <% if @import.previewing? %>
48
+ <div id="records">
48
49
  <%= raw DataPorter::Components::Preview::SummaryCards.new(report: @import.report).call %>
49
50
  <%= raw DataPorter::Components::Preview::Table.new(
50
51
  columns: @target._columns,
51
52
  records: @records
52
53
  ).call %>
54
+ </div>
55
+ <%= raw DataPorter::Components::Shared::Pagination.new(
56
+ page: @page, total_pages: @total_pages, base_url: import_path(@import)
57
+ ).call %>
53
58
 
54
59
  <div class="dp-actions">
55
- <%= button_to "Confirm", confirm_import_path(@import),
56
- method: :post, class: "dp-btn dp-btn--primary" %>
60
+ <%= button_to confirm_import_path(@import), method: :post, class: "dp-btn dp-btn--primary", data: { dp_submit: true } do %>
61
+ Confirm Import
62
+ <% end %>
57
63
  <% if @target._dry_run_enabled %>
58
- <%= button_to "Dry Run", dry_run_import_path(@import),
59
- method: :post, class: "dp-btn dp-btn--secondary" %>
64
+ <%= button_to dry_run_import_path(@import), method: :post, class: "dp-btn dp-btn--secondary", data: { dp_submit: true } do %>
65
+ Dry Run
66
+ <% end %>
60
67
  <% end %>
61
68
  <%= button_to "Cancel", cancel_import_path(@import),
62
69
  method: :post, class: "dp-btn dp-btn--danger" %>
@@ -64,7 +71,25 @@
64
71
  <% end %>
65
72
 
66
73
  <% if @import.completed? %>
67
- <%= raw DataPorter::Components::Preview::ResultsSummary.new(report: @import.report).call %>
74
+ <% duration = @import.updated_at && @import.created_at ? distance_of_time_in_words(@import.created_at, @import.updated_at) : nil %>
75
+ <%= raw DataPorter::Components::Preview::ResultsSummary.new(report: @import.report, duration: duration).call %>
76
+ <% if @records.any? %>
77
+ <div id="records">
78
+ <%= raw DataPorter::Components::Preview::Table.new(
79
+ columns: @target._columns,
80
+ records: @records
81
+ ).call %>
82
+ </div>
83
+ <%= raw DataPorter::Components::Shared::Pagination.new(
84
+ page: @page, total_pages: @total_pages, base_url: import_path(@import)
85
+ ).call %>
86
+ <% end %>
87
+ <div class="dp-actions">
88
+ <%= link_to "Back to imports", imports_path, class: "dp-btn dp-btn--primary" %>
89
+ <%= button_to "Delete", import_path(@import),
90
+ method: :delete, class: "dp-btn dp-btn--danger",
91
+ data: { turbo_confirm: "Delete this import?" } %>
92
+ </div>
68
93
  <% end %>
69
94
 
70
95
  <% if @import.failed? %>
@@ -72,6 +97,20 @@
72
97
  <div class="dp-actions">
73
98
  <%= button_to "Retry", parse_import_path(@import),
74
99
  method: :post, class: "dp-btn dp-btn--primary" %>
100
+ <%= button_to "Delete", import_path(@import),
101
+ method: :delete, class: "dp-btn dp-btn--danger",
102
+ data: { turbo_confirm: "Delete this import?" } %>
75
103
  </div>
76
104
  <% end %>
77
105
  </div>
106
+
107
+ <script>
108
+ (function() {
109
+ document.querySelectorAll("[data-dp-submit]").forEach(function(btn) {
110
+ btn.closest("form").addEventListener("submit", function() {
111
+ btn.disabled = true;
112
+ btn.innerHTML = '<span class="dp-spinner"></span>Processing...';
113
+ });
114
+ });
115
+ })();
116
+ </script>