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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cf5cdd3a072250ed1b6f8066766f4bb94c0447c80f993de6031f562ef29c969f
4
- data.tar.gz: c098c9c856c42c7f0cbdb2f0e4efef8fa298f21bac79ecc8abeb8d0c9435160a
3
+ metadata.gz: 20e2b579cf4078a611095abd155090d7754e1e33e044a022136c846edb793bcb
4
+ data.tar.gz: 58e1bcad055c198aab6ccb71ddbbb5b219d5b82a7a94a34fb8c064f109d37878
5
5
  SHA512:
6
- metadata.gz: '08d4f1bc3867112f83a6637129d6e34373e5f5b29913e116cc822fdd150447eb047cac185bb2be5c05180340672ded8c712b8f2e0ec14ca28f71c3c653381898'
7
- data.tar.gz: a8808881059e7a84fa37042a52999462c27fca0da39bfdc3828c11e4eab88d095617394443baf6974c49e6f9bb0404dd861b561d6f5f6e3c40aafe5018d0633c
6
+ metadata.gz: 9929013331242330b53fe9f5e4b4940600a339c7e2d723551e864a969406cd2c516e70a10c95eff25c820b7f7e5904b63da4156b473d140e261d3a65ed9811eb
7
+ data.tar.gz: c9be1bbff65d0da5b36b5a837f590514ed29eb911a4ba527baa3e2f228f2d27252a4f4ff9bff3af579ac6674b6be19b32f0f4bf23aab992d42291877ea019e56
data/CHANGELOG.md CHANGED
@@ -5,6 +5,59 @@ 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.9.0] - 2026-02-07
9
+
10
+ ### Added
11
+
12
+ - **Import params DSL** -- Targets declare extra form fields via `params { param :hotel_id, type: :select, ... }` with support for `:select`, `:text`, `:number`, and `:hidden` types
13
+ - **`DSL::Param` struct** -- Stores param definitions (name, type, required, label, collection, default) with type validation
14
+ - **`import_params` accessor** -- Available in all target instance methods (`persist`, `transform`, `validate`, `after_import`, `on_error`), defaults to `{}`
15
+ - **Dynamic form rendering** -- Stimulus controller and inline JS dynamically render param fields when a target is selected, with default values and required indicators
16
+ - **Required params validation** -- Controller validates required import params on create, with error messages per missing field
17
+ - **Registry params serialization** -- `Registry.available` includes serialized param definitions with collection lambdas evaluated at call time
18
+
19
+ ### Changed
20
+
21
+ - Controller concerns extracted into `ImportValidation`, `MappingManagement`, `RecordPagination`
22
+ - Orchestrator logic extracted into `RecordBuilder`, `Importer`, `DryRunner` modules
23
+ - Inline JS in `new.html.erb` extracted for consistency
24
+ - 354 RSpec examples (up from 313), 0 failures
25
+
26
+ ## [0.6.0] - 2026-02-07
27
+
28
+ ### Added
29
+
30
+ - **Records pagination** -- Preview and completed pages paginate records at 50 per page with Previous/Next navigation
31
+ - **Pagination component** -- Phlex `Shared::Pagination` with disabled states on first/last page and anchor scrolling to keep viewport on the table
32
+
33
+ ### Changed
34
+
35
+ - 313 RSpec examples (up from 300), 0 failures
36
+
37
+ ## [0.5.0] - 2026-02-07
38
+
39
+ ### Added
40
+
41
+ - **JSON polling progress** -- Lightweight `GET /imports/:id/status` endpoint replaces ActionCable dependency for zero-config real-time progress tracking
42
+ - **Progress persistence** -- Orchestrator persists progress to `config["progress"]` via `update_column` for reliable status reporting
43
+ - **Dynamic progress labels** -- Progress bar shows contextual labels (Waiting, Parsing, Importing, Dry run) with animated gradient and shimmer effect
44
+ - **Results summary redesign** -- Completed imports show icon, stat cards (imported/errors), and duration
45
+ - **Imported records table** -- Completed import page displays the full records table
46
+ - **Submit button spinners** -- Confirm/Dry Run buttons disable and show spinner on click to prevent double submission
47
+ - **Import deletion** -- Delete button on completed/failed imports (index and show pages) with confirmation dialog
48
+ - **Automatic purge** -- `rake data_porter:purge` task removes completed/failed imports older than `purge_after` (default: 60 days)
49
+ - **`purge_after` configuration** -- Customizable retention period via `config.purge_after = 60.days` in initializer
50
+ - **Per-target source filtering** -- Source type dropdown filters to only show sources allowed by the selected target's `sources` DSL
51
+ - **Server-side source validation** -- Controller rejects source types not allowed by the target
52
+ - **Generator `--sources` option** -- `rails g data_porter:target Foo name:string --sources csv xlsx` generates target with specific sources
53
+
54
+ ### Changed
55
+
56
+ - Confirm and dry_run actions set status to `pending` before enqueuing jobs to prevent race conditions
57
+ - Progress controller rewritten as JSON poller (1s interval) with auto-redirect via `Turbo.visit` on completion
58
+ - ResultsSummary component uses Unicode escapes for Phlex-safe icons
59
+ - 296 RSpec examples (up from 280), 0 failures
60
+
8
61
  ## [0.4.0] - 2026-02-07
9
62
 
10
63
  ### Added
data/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # DataPorter
2
2
 
3
- > [!WARNING]
4
- > This gem is under active development and not yet production-ready. APIs and features may change without notice.
3
+ > [!CAUTION]
4
+ > **This gem is under active development and not yet production-ready.**
5
+ > APIs and features may change without notice. Use at your own risk.
5
6
 
6
7
  A mountable Rails engine for data import workflows: **Upload**, **Map**, **Preview**, **Import**.
7
8
 
@@ -23,9 +24,12 @@ Supports CSV, JSON, XLSX, and API sources with a declarative DSL for defining im
23
24
  - **4 source types** -- CSV, XLSX, JSON, and API with a unified parsing pipeline
24
25
  - **Interactive column mapping** -- Drag-free UI to match file headers to target fields ([docs](docs/MAPPING.md))
25
26
  - **Mapping templates** -- Save and reuse column mappings across imports ([docs](docs/MAPPING.md#mapping-templates))
26
- - **Real-time progress** -- ActionCable updates with polling fallback
27
+ - **Real-time progress** -- JSON polling with animated progress bar, no ActionCable required
27
28
  - **Dry run mode** -- Validate against the database without persisting
28
29
  - **Standalone UI** -- Self-contained layout with Turbo Drive and Stimulus, no host app dependencies
30
+ - **Import params** -- Declare extra form fields (select, text, number, hidden) per target for scoped imports ([docs](docs/TARGETS.md#params--))
31
+ - **Per-target source filtering** -- Each target declares its allowed sources, the UI filters accordingly
32
+ - **Import deletion & auto-purge** -- Delete imports from the UI, or schedule `rake data_porter:purge` for automatic cleanup
29
33
  - **Declarative Target DSL** -- One class per import type, zero boilerplate ([docs](docs/TARGETS.md))
30
34
 
31
35
  ## Requirements
@@ -33,7 +37,6 @@ Supports CSV, JSON, XLSX, and API sources with a declarative DSL for defining im
33
37
  - Ruby >= 3.2
34
38
  - Rails >= 7.0
35
39
  - ActiveStorage (for file uploads)
36
- - ActionCable (optional, for real-time progress)
37
40
 
38
41
  ## Installation
39
42
 
@@ -54,7 +57,7 @@ The generator creates:
54
57
  Generate a target:
55
58
 
56
59
  ```bash
57
- bin/rails generate data_porter:target Product name:string:required price:integer sku:string
60
+ bin/rails generate data_porter:target Product name:string:required price:integer sku:string --sources csv xlsx
58
61
  ```
59
62
 
60
63
  Implement `persist` in `app/importers/product_target.rb`:
@@ -110,6 +113,7 @@ pending -> parsing -> previewing -> importing -> completed
110
113
  | [Targets](docs/TARGETS.md) | DSL reference, columns, hooks, generator |
111
114
  | [Sources](docs/SOURCES.md) | CSV, JSON, XLSX, API setup and examples |
112
115
  | [Column Mapping](docs/MAPPING.md) | Interactive mapping, templates, priority order |
116
+ | [Roadmap](docs/ROADMAP.md) | v1.0 plan and progress |
113
117
 
114
118
  ## Routes
115
119
 
@@ -118,6 +122,8 @@ pending -> parsing -> previewing -> importing -> completed
118
122
  | GET | `/imports` | List imports |
119
123
  | POST | `/imports` | Create import |
120
124
  | GET | `/imports/:id` | Show import |
125
+ | DELETE | `/imports/:id` | Delete import |
126
+ | GET | `/imports/:id/status` | JSON progress polling |
121
127
  | PATCH | `/imports/:id/update_mapping` | Save column mapping |
122
128
  | POST | `/imports/:id/parse` | Parse source |
123
129
  | POST | `/imports/:id/confirm` | Run import |
@@ -131,7 +137,7 @@ pending -> parsing -> previewing -> importing -> completed
131
137
  git clone https://github.com/SerylLns/data_porter.git
132
138
  cd data_porter
133
139
  bin/setup
134
- bundle exec rspec # 280 specs
140
+ bundle exec rspec # 354 specs
135
141
  bundle exec rubocop # 0 offenses
136
142
  ```
137
143
 
@@ -0,0 +1,126 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["targetSelect", "sourceSelect", "fileField", "fileInput", "dropzone", "fileName", "modal", "paramsContainer"]
5
+ static values = { sources: Object, params: Object }
6
+
7
+ connect() {
8
+ this.filterSources()
9
+ this.renderParams()
10
+ }
11
+
12
+ filterSources() {
13
+ if (!this.hasSourceSelectTarget || !this.hasTargetSelectTarget) return
14
+ var allowed = this.sourcesValue[this.targetSelectTarget.value]
15
+ var options = this.sourceSelectTarget.options
16
+ for (var i = 1; i < options.length; i++) {
17
+ options[i].style.display = allowed && allowed.indexOf(options[i].value) === -1 ? "none" : ""
18
+ }
19
+ if (allowed && this.sourceSelectTarget.selectedIndex > 0 && allowed.indexOf(this.sourceSelectTarget.value) === -1) {
20
+ this.sourceSelectTarget.selectedIndex = 0
21
+ this.fileFieldTarget.style.display = ""
22
+ }
23
+ this.renderParams()
24
+ }
25
+
26
+ renderParams() {
27
+ if (!this.hasParamsContainerTarget || !this.hasTargetSelectTarget) return
28
+ this.paramsContainerTarget.innerHTML = ""
29
+ var defs = this.paramsValue[this.targetSelectTarget.value] || []
30
+ var self = this
31
+ defs.forEach(function(p) { self.paramsContainerTarget.appendChild(self.buildParamField(p)) })
32
+ }
33
+
34
+ buildParamField(p) {
35
+ var div = document.createElement("div")
36
+ div.className = "dp-field"
37
+ div.appendChild(this.buildLabel(p))
38
+ div.appendChild(this.buildInput(p))
39
+ return div
40
+ }
41
+
42
+ buildLabel(p) {
43
+ var label = document.createElement("label")
44
+ label.className = "dp-label"
45
+ label.textContent = p.label + (p.required ? " *" : "")
46
+ return label
47
+ }
48
+
49
+ buildInput(p) {
50
+ if (p.type === "select" && p.collection) return this.buildSelect(p)
51
+ return this.buildTextField(p)
52
+ }
53
+
54
+ buildSelect(p) {
55
+ var select = document.createElement("select")
56
+ select.className = "dp-select"
57
+ select.name = "data_import[config][import_params][" + p.name + "]"
58
+ if (p.required) select.required = true
59
+ var blank = document.createElement("option")
60
+ blank.value = ""
61
+ blank.textContent = "Select..."
62
+ select.appendChild(blank)
63
+ p.collection.forEach(function(opt) {
64
+ var o = document.createElement("option")
65
+ o.textContent = opt[0]
66
+ o.value = opt[1]
67
+ if (p["default"] && String(opt[1]) === String(p["default"])) o.selected = true
68
+ select.appendChild(o)
69
+ })
70
+ return select
71
+ }
72
+
73
+ buildTextField(p) {
74
+ var input = document.createElement("input")
75
+ input.className = "dp-input"
76
+ input.type = p.type === "number" ? "number" : (p.type === "hidden" ? "hidden" : "text")
77
+ input.name = "data_import[config][import_params][" + p.name + "]"
78
+ if (p["default"]) input.value = p["default"]
79
+ if (p.required) input.required = true
80
+ return input
81
+ }
82
+
83
+ toggleFileField() {
84
+ this.fileFieldTarget.style.display = this.sourceSelectTarget.value === "api" ? "none" : ""
85
+ }
86
+
87
+ handleFile() {
88
+ if (this.fileInputTarget.files.length > 0) {
89
+ this.fileNameTarget.textContent = this.fileInputTarget.files[0].name
90
+ this.fileNameTarget.style.display = ""
91
+ this.dropzoneTarget.classList.add("dp-dropzone--has-file")
92
+ }
93
+ }
94
+
95
+ dragover(event) {
96
+ event.preventDefault()
97
+ this.dropzoneTarget.classList.add("dp-dropzone--dragover")
98
+ }
99
+
100
+ dragleave() {
101
+ this.dropzoneTarget.classList.remove("dp-dropzone--dragover")
102
+ }
103
+
104
+ drop(event) {
105
+ event.preventDefault()
106
+ this.dropzoneTarget.classList.remove("dp-dropzone--dragover")
107
+ if (event.dataTransfer.files.length > 0) {
108
+ this.fileInputTarget.files = event.dataTransfer.files
109
+ this.fileInputTarget.dispatchEvent(new Event("change"))
110
+ }
111
+ }
112
+
113
+ closeModal(event) {
114
+ if (event.key === "Escape") {
115
+ this.modalTarget.classList.remove("dp-modal--open")
116
+ }
117
+ }
118
+
119
+ openModal() {
120
+ this.modalTarget.classList.add("dp-modal--open")
121
+ }
122
+
123
+ closeModalClick() {
124
+ this.modalTarget.classList.remove("dp-modal--open")
125
+ }
126
+ }
@@ -0,0 +1,42 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["bar", "text", "label"]
5
+ static values = { id: Number, url: String }
6
+
7
+ connect() {
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)
29
+ }
30
+ } catch (e) {}
31
+ }
32
+
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..."
37
+ }
38
+
39
+ disconnect() {
40
+ if (this.timer) clearInterval(this.timer)
41
+ }
42
+ }
@@ -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,54 @@
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
+ }
52
+
53
+ .dp-pagination {
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ gap: 1rem;
58
+ margin: 1.5rem 0;
59
+ }
60
+
61
+ .dp-pagination__btn {
62
+ display: inline-flex;
63
+ align-items: center;
64
+ padding: 0.5rem 1rem;
65
+ font-size: 0.875rem;
66
+ font-weight: 500;
67
+ color: var(--dp-primary);
68
+ background: white;
69
+ border: 1px solid var(--dp-gray-300);
70
+ border-radius: var(--dp-radius-md);
71
+ text-decoration: none;
72
+ cursor: pointer;
73
+ transition: all 0.15s ease;
74
+ }
75
+
76
+ .dp-pagination__btn:hover {
77
+ background: var(--dp-gray-50);
78
+ border-color: var(--dp-primary);
79
+ }
80
+
81
+ .dp-pagination__btn--disabled {
82
+ color: var(--dp-gray-400);
83
+ cursor: default;
84
+ pointer-events: none;
85
+ }
86
+
87
+ .dp-pagination__btn--disabled:hover {
88
+ background: white;
89
+ border-color: var(--dp-gray-300);
90
+ }
91
+
92
+ .dp-pagination__info {
93
+ font-size: 0.875rem;
94
+ color: var(--dp-gray-500);
95
+ font-weight: 500;
96
+ }
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Concerns
5
+ module ImportValidation
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def valid_source_for_target?
11
+ target = DataPorter::Registry.find(@import.target_key)
12
+ allowed = target._sources || DataPorter.configuration.enabled_sources
13
+ return true if allowed.map(&:to_s).include?(@import.source_type.to_s)
14
+
15
+ @import.errors.add(:source_type, "#{@import.source_type} is not available for this target")
16
+ false
17
+ end
18
+
19
+ def valid_file_presence?
20
+ return true unless %w[csv json xlsx].include?(@import.source_type)
21
+ return true if @import.file.attached?
22
+
23
+ @import.errors.add(:file, "must be attached for #{@import.source_type.upcase} imports")
24
+ false
25
+ end
26
+
27
+ def valid_import_params?
28
+ missing = missing_required_params
29
+ return true if missing.empty?
30
+
31
+ missing.each { |p| @import.errors.add(:base, "#{p.label} is required") }
32
+ false
33
+ end
34
+
35
+ def missing_required_params
36
+ target = DataPorter::Registry.find(@import.target_key)
37
+ required = (target._params || []).select(&:required)
38
+ values = import_param_values
39
+ required.reject { |p| values[p.name.to_s].present? }
40
+ end
41
+
42
+ def import_param_values
43
+ (@import.config || {}).fetch("import_params", {})
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Concerns
5
+ module MappingManagement
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def load_mapping_data
11
+ target = @import.target_class
12
+ columns = target._columns || []
13
+ @file_headers = @import.config["file_headers"] || []
14
+ @target_columns = columns.map { |c| [c.label, c.name.to_s, c.required] }
15
+ @default_mapping = (target._csv_mappings || {}).transform_values(&:to_s)
16
+ @templates = load_templates
17
+ end
18
+
19
+ def load_templates
20
+ return [] unless defined?(DataPorter::MappingTemplate)
21
+
22
+ DataPorter::MappingTemplate.for_target(@import.target_key)
23
+ end
24
+
25
+ def save_column_mapping
26
+ mapping = params.require(:column_mapping).permit!.to_h
27
+ merged = (@import.config || {}).merge("column_mapping" => mapping)
28
+ @import.update!(config: merged, status: :pending)
29
+ end
30
+
31
+ def save_template_if_requested
32
+ return unless params[:save_template] == "1"
33
+ return unless defined?(DataPorter::MappingTemplate)
34
+
35
+ mapping = params.require(:column_mapping).permit!.to_h
36
+ DataPorter::MappingTemplate.find_or_initialize_by(
37
+ target_key: @import.target_key,
38
+ name: params[:template_name].presence || "Default"
39
+ ).update!(mapping: mapping)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module Concerns
5
+ module RecordPagination
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def paginate_records
11
+ per_page = 50
12
+ @total_pages = (@records.size.to_f / per_page).ceil
13
+ @total_pages = 1 if @total_pages.zero?
14
+ @page = (params[:page] || 1).to_i.clamp(1, @total_pages)
15
+ @records = @records.slice((@page - 1) * per_page, per_page) || []
16
+ end
17
+ end
18
+ end
19
+ end