data_porter 2.1.1 → 2.4.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/ROADMAP.md +0 -30
  4. data/app/assets/javascripts/data_porter/theme_controller.js +33 -0
  5. data/app/assets/stylesheets/data_porter/alerts.css +3 -3
  6. data/app/assets/stylesheets/data_porter/application.css +1 -0
  7. data/app/assets/stylesheets/data_porter/base.css +14 -6
  8. data/app/assets/stylesheets/data_porter/cards.css +1 -1
  9. data/app/assets/stylesheets/data_porter/dark.css +73 -0
  10. data/app/assets/stylesheets/data_porter/layout.css +33 -6
  11. data/app/assets/stylesheets/data_porter/mapping.css +9 -9
  12. data/app/assets/stylesheets/data_porter/modal.css +1 -1
  13. data/app/assets/stylesheets/data_porter/preview.css +1 -1
  14. data/app/assets/stylesheets/data_porter/progress.css +1 -1
  15. data/app/assets/stylesheets/data_porter/table.css +3 -3
  16. data/app/jobs/data_porter/webhook_job.rb +45 -0
  17. data/app/views/layouts/data_porter/application.html.erb +6 -2
  18. data/lib/data_porter/column_transformer.rb +56 -0
  19. data/lib/data_porter/configuration.rb +3 -1
  20. data/lib/data_porter/dsl/column.rb +3 -2
  21. data/lib/data_porter/dsl/webhook.rb +31 -0
  22. data/lib/data_porter/engine.rb +1 -0
  23. data/lib/data_porter/orchestrator/importer.rb +1 -0
  24. data/lib/data_porter/orchestrator/record_builder.rb +1 -0
  25. data/lib/data_porter/orchestrator.rb +3 -0
  26. data/lib/data_porter/target.rb +11 -1
  27. data/lib/data_porter/version.rb +1 -1
  28. data/lib/data_porter/webhook_notifier.rb +100 -0
  29. data/lib/data_porter.rb +2 -0
  30. data/lib/generators/data_porter/install/templates/initializer.rb +5 -0
  31. data/lib/generators/data_porter/views/views_generator.rb +43 -0
  32. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '04931dd3e74ae9a12b2225ddb1c5467022458013d49fb35a07282121296ec22b'
4
- data.tar.gz: 4d0d04002509ae87358375f0df64bec0d8c134201131ead1a1a07460c9e19297
3
+ metadata.gz: b393c618ddb47334a61a62de03796211188b58367cad71bfc8574874208a34db
4
+ data.tar.gz: 9eaa6b01ea0127bd22fa8557cccc34f47070e58991512f66996df6f9b789e8e3
5
5
  SHA512:
6
- metadata.gz: 11ba16dcc818425722fc20e1a919833e4c4f77def52c07b46068707a1e511bb045bf7801edfb9b3186750fbf242a633d526e81e6b1bf3cd4b54b806c47e701aa
7
- data.tar.gz: 4c99d6a1c4d04e4cee197199db9dd3517bc7d44b283e511a7289e46703ddf3d69f91a8cd7e4fb4f43ce5e8fdaaccbe805b5a649846d1c5e096a7803eafb07442
6
+ metadata.gz: 9f5e73c6eb11dccd143496f4147c941d2729a17643b10eed46f092e4615a2a861d1359b6993dadddaa7ab256672646a25b93f73f77bf238b1b4543fb7c92eb26
7
+ data.tar.gz: 3f2c4be7e4dbe828a57bd81709c64b5c592c71aabaae0e604643216455c2c3bfc7ef74d14b78241658104c996125495587cfff73301c5a8f918abca64aca8e4f
data/CHANGELOG.md CHANGED
@@ -5,6 +5,40 @@ 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
+ ## [2.4.0] - 2026-02-21
9
+
10
+ ### Added
11
+
12
+ - **View generator** -- `rails g data_porter:views` copies ERB templates into the host app for structural customization. Supports scoped copy: `imports`, `mapping_templates`, `layout`
13
+ - **Dark mode** -- Built-in dark theme via `.dp-dark` CSS class with automatic OS preference detection via `@media (prefers-color-scheme: dark)`. Theme toggle button with localStorage persistence
14
+ - **Semantic CSS variables** -- 8 high-level `--dp-*` variables (`--dp-bg`, `--dp-bg-elevated`, `--dp-text`, etc.) for easy theming without modifying templates
15
+
16
+ ### Changed
17
+
18
+ - All hardcoded `white` backgrounds replaced with semantic CSS variables across 8 stylesheets
19
+ - Light mode color palette strengthened for better contrast (success, warning, danger backgrounds)
20
+ - 519 RSpec examples (up from 508), 0 failures
21
+
22
+ ## [2.3.0] - 2026-02-20
23
+
24
+ ### Added
25
+
26
+ - **Webhooks** -- Per-target HTTP callbacks on import lifecycle events (`import.started`, `import.parsed`, `import.completed`, `import.failed`). Declarative DSL via `webhooks do webhook(url, events:, headers:, payload:) end`. HMAC-SHA256 request signing via `config.webhook_secret`. Async delivery via `WebhookJob` (fire-and-forget, 10s timeout). Custom payload lambdas and per-webhook headers supported
27
+
28
+ ### Changed
29
+
30
+ - 508 RSpec examples (up from 466), 0 failures
31
+
32
+ ## [2.2.0] - 2026-02-20
33
+
34
+ ### Added
35
+
36
+ - **Column transformers** -- Declarative per-column transformation pipeline via `transform: [:strip, :downcase]` in the columns DSL. Applied automatically before the target's `transform` method. Ships with 9 built-in transformers (`strip`, `downcase`, `upcase`, `titleize`, `normalize_phone`, `parse_date`, `parse_boolean`, `parse_integer`, `parse_decimal`). Custom transformers via `DataPorter::ColumnTransformer.register(:name) { |v| ... }`
37
+
38
+ ### Changed
39
+
40
+ - 466 RSpec examples (up from 438), 0 failures
41
+
8
42
  ## [2.1.1] - 2026-02-20
9
43
 
10
44
  ### Fixed
data/ROADMAP.md CHANGED
@@ -2,28 +2,6 @@
2
2
 
3
3
  ## Next
4
4
 
5
- ### Column transformers
6
-
7
- Built-in transformation pipeline applied per-column before the target's `transform` method. Declarative DSL in the target:
8
-
9
- ```ruby
10
- columns do
11
- column :email, type: :string, transform: [:strip, :downcase]
12
- column :phone, type: :string, transform: [:strip, :normalize_phone]
13
- column :born_on, type: :date, transform: [:parse_date]
14
- end
15
- ```
16
-
17
- Ships with common transformers (`strip`, `downcase`, `titleize`, `normalize_phone`, `parse_date`). Custom transformers via a registry.
18
-
19
- ### Webhooks
20
-
21
- HTTP callbacks on import lifecycle events (started, completed, failed). Configurable per-target with URL, headers, and payload template. Enables integration with Slack notifications, CI pipelines, or external dashboards.
22
-
23
- ---
24
-
25
- ## Planned
26
-
27
5
  ### Bulk import
28
6
 
29
7
  High-volume import support using `insert_all` / `upsert_all` for batch persistence. Opt-in per target to bypass per-record `persist` calls, enabling 10-100x throughput for simple create/upsert scenarios. Configurable batch size, with fallback to per-record mode on conflict.
@@ -56,14 +34,6 @@ Headless REST API for programmatic imports:
56
34
  - Auth via `config.api_authenticate` lambda (API key or Bearer token)
57
35
  - Reuses existing job pipeline (parse, import, dry run)
58
36
 
59
- ### View generator & theming
60
-
61
- Customizable UI in two layers:
62
-
63
- - **View generator** — `rails g data_porter:views` copies the 7 ERB templates into the host app for structural customization (layout, buttons, sections). Similar to `devise:views`.
64
- - **CSS theming** — All styles use `--dp-*` custom properties. Host apps override variables to match their design system, no ERB changes needed.
65
- - **Light / dark mode** — Two built-in presets toggled via `prefers-color-scheme` or a `.dp-dark` class.
66
-
67
37
  ### Auto-map heuristics
68
38
 
69
39
  Smart column mapping suggestions using tokenized header matching and synonym dictionaries. When a CSV has "E-mail Address", auto-suggest mapping to `:email`. Built-in synonyms for common patterns (phone → phone_number, first name → first_name). Configurable synonym lists per target.
@@ -0,0 +1,33 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["icon"]
5
+
6
+ connect() {
7
+ this.applyTheme(this.currentTheme())
8
+ }
9
+
10
+ toggle() {
11
+ const next = this.currentTheme() === "dark" ? "light" : "dark"
12
+ localStorage.setItem("dp-theme", next)
13
+ this.applyTheme(next)
14
+ }
15
+
16
+ currentTheme() {
17
+ const stored = localStorage.getItem("dp-theme")
18
+ if (stored) return stored
19
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
20
+ }
21
+
22
+ applyTheme(theme) {
23
+ const root = this.element
24
+ if (theme === "dark") {
25
+ root.classList.add("dp-dark")
26
+ } else {
27
+ root.classList.remove("dp-dark")
28
+ }
29
+ if (this.hasIconTarget) {
30
+ this.iconTarget.textContent = theme === "dark" ? "\u2600" : "\u263D"
31
+ }
32
+ }
33
+ }
@@ -12,8 +12,8 @@
12
12
  }
13
13
 
14
14
  .dp-results--partial {
15
- background: var(--dp-warning-light);
16
- border: 1px solid var(--dp-warning-border);
15
+ background: var(--dp-danger-light);
16
+ border: 1px solid var(--dp-danger-border);
17
17
  }
18
18
 
19
19
  .dp-results__icon {
@@ -39,7 +39,7 @@
39
39
  .dp-results__stat {
40
40
  padding: 1rem;
41
41
  border-radius: var(--dp-radius-md);
42
- background: white;
42
+ background: var(--dp-bg-elevated);
43
43
  border: 1px solid var(--dp-gray-200);
44
44
  }
45
45
 
@@ -9,4 +9,5 @@
9
9
  *= require data_porter/alerts
10
10
  *= require data_porter/modal
11
11
  *= require data_porter/mapping
12
+ *= require data_porter/dark
12
13
  */
@@ -3,15 +3,15 @@
3
3
  --dp-primary-hover: #4338ca;
4
4
  --dp-primary-light: #eef2ff;
5
5
  --dp-success: #059669;
6
- --dp-success-light: #ecfdf5;
7
- --dp-success-border: #a7f3d0;
6
+ --dp-success-light: #d1fae5;
7
+ --dp-success-border: #6ee7b7;
8
8
  --dp-warning: #d97706;
9
- --dp-warning-light: #fffbeb;
10
- --dp-warning-border: #fde68a;
9
+ --dp-warning-light: #fef3c7;
10
+ --dp-warning-border: #fcd34d;
11
11
  --dp-danger: #dc2626;
12
12
  --dp-danger-hover: #b91c1c;
13
- --dp-danger-light: #fef2f2;
14
- --dp-danger-border: #fecaca;
13
+ --dp-danger-light: #fee2e2;
14
+ --dp-danger-border: #fca5a5;
15
15
  --dp-info: #2563eb;
16
16
  --dp-info-light: #eff6ff;
17
17
  --dp-info-border: #bfdbfe;
@@ -35,6 +35,14 @@
35
35
  --dp-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
36
36
  --dp-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
37
37
  --dp-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
38
+ --dp-bg: white;
39
+ --dp-bg-elevated: white;
40
+ --dp-bg-subtle: var(--dp-gray-50);
41
+ --dp-text: var(--dp-gray-800);
42
+ --dp-text-heading: var(--dp-gray-900);
43
+ --dp-text-muted: var(--dp-gray-500);
44
+ --dp-border: var(--dp-gray-200);
45
+ --dp-border-strong: var(--dp-gray-300);
38
46
  }
39
47
 
40
48
  .data-porter {
@@ -10,7 +10,7 @@
10
10
  border-radius: var(--dp-radius-lg);
11
11
  border: 1px solid var(--dp-gray-200);
12
12
  text-align: center;
13
- background: white;
13
+ background: var(--dp-bg-elevated);
14
14
  box-shadow: var(--dp-shadow-sm);
15
15
  transition: transform 0.15s ease, box-shadow 0.15s ease;
16
16
  }
@@ -0,0 +1,73 @@
1
+ .dp-dark {
2
+ background: #111827;
3
+ color: #e5e7eb;
4
+ --dp-bg: #111827;
5
+ --dp-bg-elevated: #1f2937;
6
+ --dp-bg-subtle: #1f2937;
7
+ --dp-text: #e5e7eb;
8
+ --dp-text-heading: #f9fafb;
9
+ --dp-text-muted: #9ca3af;
10
+ --dp-border: #374151;
11
+ --dp-border-strong: #4b5563;
12
+ --dp-gray-50: #1f2937;
13
+ --dp-gray-100: #374151;
14
+ --dp-gray-200: #374151;
15
+ --dp-gray-300: #4b5563;
16
+ --dp-gray-400: #6b7280;
17
+ --dp-gray-500: #9ca3af;
18
+ --dp-gray-600: #d1d5db;
19
+ --dp-gray-700: #e5e7eb;
20
+ --dp-gray-800: #e5e7eb;
21
+ --dp-gray-900: #f9fafb;
22
+ --dp-primary-light: rgba(79, 70, 229, 0.25);
23
+ --dp-success-light: rgba(5, 150, 105, 0.25);
24
+ --dp-success-border: rgba(5, 150, 105, 0.4);
25
+ --dp-warning-light: rgba(217, 119, 6, 0.25);
26
+ --dp-warning-border: rgba(217, 119, 6, 0.4);
27
+ --dp-danger-light: rgba(220, 38, 38, 0.25);
28
+ --dp-danger-border: rgba(220, 38, 38, 0.4);
29
+ --dp-info-light: rgba(37, 99, 235, 0.25);
30
+ --dp-info-border: rgba(37, 99, 235, 0.4);
31
+ --dp-purple-light: rgba(124, 58, 237, 0.25);
32
+ --dp-purple-border: rgba(124, 58, 237, 0.4);
33
+ --dp-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
34
+ --dp-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
35
+ --dp-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
36
+ }
37
+
38
+ @media (prefers-color-scheme: dark) {
39
+ .dp-auto {
40
+ --dp-bg: #111827;
41
+ --dp-bg-elevated: #1f2937;
42
+ --dp-bg-subtle: #1f2937;
43
+ --dp-text: #e5e7eb;
44
+ --dp-text-heading: #f9fafb;
45
+ --dp-text-muted: #9ca3af;
46
+ --dp-border: #374151;
47
+ --dp-border-strong: #4b5563;
48
+ --dp-gray-50: #1f2937;
49
+ --dp-gray-100: #374151;
50
+ --dp-gray-200: #374151;
51
+ --dp-gray-300: #4b5563;
52
+ --dp-gray-400: #6b7280;
53
+ --dp-gray-500: #9ca3af;
54
+ --dp-gray-600: #d1d5db;
55
+ --dp-gray-700: #e5e7eb;
56
+ --dp-gray-800: #e5e7eb;
57
+ --dp-gray-900: #f9fafb;
58
+ --dp-primary-light: rgba(79, 70, 229, 0.25);
59
+ --dp-success-light: rgba(5, 150, 105, 0.25);
60
+ --dp-success-border: rgba(5, 150, 105, 0.4);
61
+ --dp-warning-light: rgba(217, 119, 6, 0.25);
62
+ --dp-warning-border: rgba(217, 119, 6, 0.4);
63
+ --dp-danger-light: rgba(220, 38, 38, 0.25);
64
+ --dp-danger-border: rgba(220, 38, 38, 0.4);
65
+ --dp-info-light: rgba(37, 99, 235, 0.25);
66
+ --dp-info-border: rgba(37, 99, 235, 0.4);
67
+ --dp-purple-light: rgba(124, 58, 237, 0.25);
68
+ --dp-purple-border: rgba(124, 58, 237, 0.4);
69
+ --dp-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
70
+ --dp-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
71
+ --dp-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
72
+ }
73
+ }
@@ -12,7 +12,7 @@
12
12
 
13
13
  .dp-form {
14
14
  max-width: 520px;
15
- background: white;
15
+ background: var(--dp-bg-elevated);
16
16
  padding: 2rem;
17
17
  border: 1px solid var(--dp-gray-200);
18
18
  border-radius: var(--dp-radius-lg);
@@ -36,8 +36,8 @@
36
36
  border: 1px solid var(--dp-gray-300);
37
37
  border-radius: var(--dp-radius-md);
38
38
  font-size: 0.9375rem;
39
- background: white;
40
- color: var(--dp-gray-800);
39
+ background: var(--dp-bg-elevated);
40
+ color: var(--dp-text);
41
41
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
42
42
  appearance: none;
43
43
  }
@@ -95,7 +95,7 @@
95
95
  .dp-btn--primary { background: var(--dp-primary); color: white; box-shadow: var(--dp-shadow-sm); }
96
96
  .dp-btn--primary:hover { background: var(--dp-primary-hover); box-shadow: var(--dp-shadow-md); }
97
97
 
98
- .dp-btn--secondary { background: white; color: var(--dp-gray-700); border-color: var(--dp-gray-300); box-shadow: var(--dp-shadow-sm); }
98
+ .dp-btn--secondary { background: var(--dp-bg-elevated); color: var(--dp-gray-700); border-color: var(--dp-gray-300); box-shadow: var(--dp-shadow-sm); }
99
99
  .dp-btn--secondary:hover { background: var(--dp-gray-50); border-color: var(--dp-gray-400); }
100
100
 
101
101
  .dp-btn--danger { background: var(--dp-danger); color: white; box-shadow: var(--dp-shadow-sm); }
@@ -112,7 +112,7 @@
112
112
  .dp-empty-state {
113
113
  text-align: center;
114
114
  padding: 4rem 2rem;
115
- background: white;
115
+ background: var(--dp-bg-elevated);
116
116
  border: 2px dashed var(--dp-gray-300);
117
117
  border-radius: var(--dp-radius-xl);
118
118
  }
@@ -121,7 +121,7 @@
121
121
  .dp-empty-state__text { font-size: 1.125rem; color: var(--dp-gray-500); margin: 0 0 1.5rem; }
122
122
 
123
123
  .dp-import-details {
124
- background: white;
124
+ background: var(--dp-bg-elevated);
125
125
  padding: 1.5rem;
126
126
  border: 1px solid var(--dp-gray-200);
127
127
  border-radius: var(--dp-radius-lg);
@@ -145,3 +145,30 @@
145
145
  margin: 0;
146
146
  color: var(--dp-gray-800);
147
147
  }
148
+
149
+ .dp-theme-toggle {
150
+ position: fixed;
151
+ bottom: 1.5rem;
152
+ right: 1.5rem;
153
+ width: 2.75rem;
154
+ height: 2.75rem;
155
+ border-radius: 50%;
156
+ border: 1px solid var(--dp-border-strong);
157
+ background: var(--dp-bg-elevated);
158
+ color: var(--dp-text-muted);
159
+ font-size: 1.25rem;
160
+ cursor: pointer;
161
+ box-shadow: var(--dp-shadow-md);
162
+ transition: all 0.15s ease;
163
+ display: flex;
164
+ align-items: center;
165
+ justify-content: center;
166
+ z-index: 9998;
167
+ }
168
+
169
+ .dp-theme-toggle:hover {
170
+ background: var(--dp-bg-subtle);
171
+ color: var(--dp-text);
172
+ box-shadow: var(--dp-shadow-lg);
173
+ transform: scale(1.05);
174
+ }
@@ -1,6 +1,6 @@
1
1
  .dp-mapping-form {
2
2
  max-width: 700px;
3
- background: white;
3
+ background: var(--dp-bg-elevated);
4
4
  padding: 2rem;
5
5
  border: 1px solid var(--dp-gray-200);
6
6
  border-radius: var(--dp-radius-lg);
@@ -53,8 +53,8 @@
53
53
  }
54
54
 
55
55
  .dp-mapping-row--duplicate {
56
- border-color: #f59e0b;
57
- background: #fffbeb;
56
+ border-color: var(--dp-warning-border);
57
+ background: var(--dp-warning-light);
58
58
  }
59
59
 
60
60
  .dp-mapping-required-warning,
@@ -67,13 +67,13 @@
67
67
  }
68
68
 
69
69
  .dp-mapping-required-warning {
70
- background: #fef2f2;
71
- border: 1px solid #fca5a5;
72
- color: #991b1b;
70
+ background: var(--dp-danger-light);
71
+ border: 1px solid var(--dp-danger-border);
72
+ color: var(--dp-danger);
73
73
  }
74
74
 
75
75
  .dp-mapping-duplicate-warning {
76
- background: #fffbeb;
77
- border: 1px solid #fcd34d;
78
- color: #92400e;
76
+ background: var(--dp-warning-light);
77
+ border: 1px solid var(--dp-warning-border);
78
+ color: var(--dp-warning);
79
79
  }
@@ -3,7 +3,7 @@
3
3
  .dp-modal__backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); backdrop-filter: blur(4px); }
4
4
  .dp-modal__content {
5
5
  position: relative;
6
- background: white;
6
+ background: var(--dp-bg-elevated);
7
7
  border-radius: var(--dp-radius-xl);
8
8
  box-shadow: var(--dp-shadow-lg), 0 25px 50px -12px rgba(0,0,0,0.15);
9
9
  width: 100%;
@@ -14,7 +14,7 @@
14
14
  }
15
15
 
16
16
  .dp-row--complete { background: var(--dp-success-light); }
17
- .dp-row--partial { background: var(--dp-warning-light); }
17
+ .dp-row--partial { background: var(--dp-danger-light); }
18
18
  .dp-row--missing { background: var(--dp-danger-light); }
19
19
 
20
20
  .dp-errors {
@@ -1,7 +1,7 @@
1
1
  .dp-progress-container {
2
2
  margin: 2rem 0;
3
3
  padding: 2rem;
4
- background: white;
4
+ background: var(--dp-bg-elevated);
5
5
  border: 1px solid var(--dp-gray-200);
6
6
  border-radius: var(--dp-radius-lg);
7
7
  box-shadow: var(--dp-shadow-sm);
@@ -3,7 +3,7 @@
3
3
  border-collapse: separate;
4
4
  border-spacing: 0;
5
5
  margin-bottom: 1.5rem;
6
- background: white;
6
+ background: var(--dp-bg-elevated);
7
7
  border: 1px solid var(--dp-gray-200);
8
8
  border-radius: var(--dp-radius-lg);
9
9
  overflow: hidden;
@@ -65,7 +65,7 @@
65
65
  font-size: 0.875rem;
66
66
  font-weight: 500;
67
67
  color: var(--dp-primary);
68
- background: white;
68
+ background: var(--dp-bg-elevated);
69
69
  border: 1px solid var(--dp-gray-300);
70
70
  border-radius: var(--dp-radius-md);
71
71
  text-decoration: none;
@@ -85,7 +85,7 @@
85
85
  }
86
86
 
87
87
  .dp-pagination__btn--disabled:hover {
88
- background: white;
88
+ background: var(--dp-bg-elevated);
89
89
  border-color: var(--dp-gray-300);
90
90
  }
91
91
 
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+
6
+ module DataPorter
7
+ class WebhookJob < ActiveJob::Base
8
+ queue_as { DataPorter.configuration.queue_name }
9
+
10
+ def perform(url, payload_json, headers = {})
11
+ uri = URI.parse(url)
12
+ request = build_request(uri, payload_json, headers)
13
+ execute_request(uri, request)
14
+ end
15
+
16
+ private
17
+
18
+ def build_request(uri, payload_json, headers)
19
+ request = Net::HTTP::Post.new(uri.request_uri)
20
+ request["Content-Type"] = "application/json"
21
+ headers.each { |key, value| request[key] = value }
22
+ sign_request(request, payload_json)
23
+ request.body = payload_json
24
+ request
25
+ end
26
+
27
+ def sign_request(request, payload_json)
28
+ secret = DataPorter.configuration.webhook_secret
29
+ return unless secret
30
+
31
+ digest = OpenSSL::HMAC.hexdigest("SHA256", secret, payload_json)
32
+ request["X-DataPorter-Signature"] = "sha256=#{digest}"
33
+ end
34
+
35
+ def execute_request(uri, request)
36
+ http = Net::HTTP.new(uri.host, uri.port)
37
+ http.use_ssl = uri.scheme == "https"
38
+ http.open_timeout = 10
39
+ http.read_timeout = 10
40
+ http.request(request)
41
+ rescue StandardError => e
42
+ Rails.logger.error("[DataPorter] Webhook delivery failed: #{e.message}")
43
+ end
44
+ end
45
+ end
@@ -14,7 +14,8 @@
14
14
  "data_porter/mapping_controller": "<%= asset_path('data_porter/mapping_controller.js') %>",
15
15
  "data_porter/template_form_controller": "<%= asset_path('data_porter/template_form_controller.js') %>",
16
16
  "data_porter/progress_controller": "<%= asset_path('data_porter/progress_controller.js') %>",
17
- "data_porter/import_form_controller": "<%= asset_path('data_porter/import_form_controller.js') %>"
17
+ "data_porter/import_form_controller": "<%= asset_path('data_porter/import_form_controller.js') %>",
18
+ "data_porter/theme_controller": "<%= asset_path('data_porter/theme_controller.js') %>"
18
19
  }
19
20
  }
20
21
  </script>
@@ -25,15 +26,18 @@
25
26
  import TemplateFormController from "data_porter/template_form_controller"
26
27
  import ProgressController from "data_porter/progress_controller"
27
28
  import ImportFormController from "data_porter/import_form_controller"
29
+ import ThemeController from "data_porter/theme_controller"
28
30
 
29
31
  const application = Application.start()
30
32
  application.register("data-porter--mapping", MappingController)
31
33
  application.register("data-porter--template-form", TemplateFormController)
32
34
  application.register("data-porter--progress", ProgressController)
33
35
  application.register("data-porter--import-form", ImportFormController)
36
+ application.register("data-porter--theme", ThemeController)
34
37
  </script>
35
38
  </head>
36
- <body>
39
+ <body data-controller="data-porter--theme">
37
40
  <%= yield %>
41
+ <button class="dp-theme-toggle" data-action="data-porter--theme#toggle" data-data-porter--theme-target="icon" aria-label="Toggle theme">&#9789;</button>
38
42
  </body>
39
43
  </html>
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module ColumnTransformer
5
+ BUILT_IN = {
6
+ strip: :strip.to_proc,
7
+ downcase: :downcase.to_proc,
8
+ upcase: :upcase.to_proc,
9
+ titleize: :titleize.to_proc,
10
+ normalize_phone: ->(value) { value.gsub(/[\s\-().]+/, "") },
11
+ parse_date: ->(value) { Date.parse(value).iso8601 rescue value }, # rubocop:disable Style/RescueModifier
12
+ parse_boolean: ->(value) { %w[true 1 yes oui].include?(value.downcase) ? "true" : "false" },
13
+ parse_integer: ->(value) { Float(value, exception: false) ? value.to_f.to_i.to_s : value },
14
+ parse_decimal: ->(value) { Float(value, exception: false)&.to_s || value }
15
+ }.freeze
16
+
17
+ def self.apply(value, transformer_name)
18
+ return value if value.nil?
19
+
20
+ transformer = BUILT_IN[transformer_name] || custom_transformers[transformer_name]
21
+ raise Error, "Unknown transformer: #{transformer_name}" unless transformer
22
+
23
+ transformer.call(value.to_s)
24
+ end
25
+
26
+ def self.register(name, &block)
27
+ custom_transformers[name.to_sym] = block
28
+ end
29
+
30
+ def self.apply_all(record, columns)
31
+ columns.each do |col|
32
+ next if col.transform.empty?
33
+
34
+ key = resolve_key(record.data, col.name)
35
+ next unless key
36
+
37
+ col.transform.each do |t|
38
+ record.data[key] = apply(record.data[key], t)
39
+ end
40
+ end
41
+ end
42
+
43
+ def self.resolve_key(data, name)
44
+ return name.to_s if data.key?(name.to_s)
45
+ return name if data.key?(name)
46
+
47
+ nil
48
+ end
49
+
50
+ def self.custom_transformers
51
+ @custom_transformers ||= {}
52
+ end
53
+
54
+ private_class_method :custom_transformers, :resolve_key
55
+ end
56
+ end
@@ -13,7 +13,8 @@ module DataPorter
13
13
  :purge_after,
14
14
  :max_file_size,
15
15
  :max_records,
16
- :transaction_mode
16
+ :transaction_mode,
17
+ :webhook_secret
17
18
 
18
19
  def initialize
19
20
  @parent_controller = "ActionController::Base"
@@ -28,6 +29,7 @@ module DataPorter
28
29
  @max_file_size = 10.megabytes
29
30
  @max_records = 10_000
30
31
  @transaction_mode = :per_record
32
+ @webhook_secret = nil
31
33
  end
32
34
  end
33
35
  end
@@ -2,13 +2,14 @@
2
2
 
3
3
  module DataPorter
4
4
  module DSL
5
- Column = Struct.new(:name, :type, :required, :label, :options, keyword_init: true) do
6
- def initialize(name:, type: :string, required: false, label: nil, **options)
5
+ Column = Struct.new(:name, :type, :required, :label, :transform, :options, keyword_init: true) do
6
+ def initialize(name:, type: :string, required: false, label: nil, transform: [], **options)
7
7
  super(
8
8
  name: name.to_sym,
9
9
  type: type.to_sym,
10
10
  required: required,
11
11
  label: label || name.to_s.humanize,
12
+ transform: Array(transform),
12
13
  options: options
13
14
  )
14
15
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module DSL
5
+ VALID_WEBHOOK_EVENTS = %i[started completed failed parsed].freeze
6
+
7
+ Webhook = Struct.new(:url, :events, :headers, :payload, keyword_init: true) do
8
+ def initialize(url:, events: VALID_WEBHOOK_EVENTS, headers: {}, payload: nil)
9
+ validate_url!(url)
10
+ events = events.map(&:to_sym)
11
+ validate_events!(events)
12
+ super
13
+ end
14
+
15
+ private
16
+
17
+ def validate_url!(url)
18
+ raise ArgumentError, "url is required" if url.nil? || url.to_s.strip.empty?
19
+ end
20
+
21
+ def validate_events!(events)
22
+ events.each do |event|
23
+ next if VALID_WEBHOOK_EVENTS.include?(event)
24
+
25
+ raise ArgumentError,
26
+ "invalid webhook event: #{event}. Must be one of: #{VALID_WEBHOOK_EVENTS.join(", ")}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -18,6 +18,7 @@ module DataPorter
18
18
  data_porter/template_form_controller.js
19
19
  data_porter/progress_controller.js
20
20
  data_porter/import_form_controller.js
21
+ data_porter/theme_controller.js
21
22
  ]
22
23
  end
23
24
  end
@@ -45,6 +45,7 @@ module DataPorter
45
45
  def finalize_import(results)
46
46
  @data_import.update!(status: :completed)
47
47
  @broadcaster.success
48
+ WebhookNotifier.notify(@data_import, "import.completed")
48
49
  results
49
50
  end
50
51
 
@@ -30,6 +30,7 @@ module DataPorter
30
30
  line_number: index + 1,
31
31
  data: extract_data(row, columns)
32
32
  )
33
+ ColumnTransformer.apply_all(record, columns)
33
34
  record = @target.transform(record)
34
35
  @target.validate(record)
35
36
  validator.validate(record)
@@ -32,12 +32,14 @@ module DataPorter
32
32
  records = build_records
33
33
  @data_import.update!(records: records, status: :previewing)
34
34
  build_report
35
+ WebhookNotifier.notify(@data_import, "import.parsed")
35
36
  rescue StandardError => e
36
37
  handle_failure(e)
37
38
  end
38
39
 
39
40
  def import!
40
41
  @data_import.importing!
42
+ WebhookNotifier.notify(@data_import, "import.started")
41
43
  results = import_records
42
44
  update_import_report(results)
43
45
  @target.after_import(results, context: build_context)
@@ -102,6 +104,7 @@ module DataPorter
102
104
  )
103
105
  @data_import.update!(status: :failed, report: report)
104
106
  @broadcaster.failure(error.message)
107
+ WebhookNotifier.notify(@data_import, "import.failed")
105
108
  end
106
109
  end
107
110
  end
@@ -3,13 +3,14 @@
3
3
  require_relative "dsl/column"
4
4
  require_relative "dsl/param"
5
5
  require_relative "dsl/api_config"
6
+ require_relative "dsl/webhook"
6
7
 
7
8
  module DataPorter
8
9
  class Target
9
10
  class << self
10
11
  attr_reader :_label, :_model_name, :_icon, :_sources,
11
12
  :_columns, :_csv_mappings, :_dedup_keys, :_json_root,
12
- :_api_config, :_dry_run_enabled, :_params
13
+ :_api_config, :_dry_run_enabled, :_params, :_webhooks
13
14
 
14
15
  def label(value)
15
16
  @_label = value
@@ -72,6 +73,15 @@ module DataPorter
72
73
  @_params << DSL::Param.new(name: name, **)
73
74
  end
74
75
 
76
+ def webhooks(&)
77
+ @_webhooks = []
78
+ instance_eval(&)
79
+ end
80
+
81
+ def webhook(url, **)
82
+ @_webhooks << DSL::Webhook.new(url: url, **)
83
+ end
84
+
75
85
  private
76
86
 
77
87
  def auto_register
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DataPorter
4
- VERSION = "2.1.1"
4
+ VERSION = "2.4.0"
5
5
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module WebhookNotifier
5
+ EVENT_MAP = {
6
+ "import.started" => :started,
7
+ "import.completed" => :completed,
8
+ "import.failed" => :failed,
9
+ "import.parsed" => :parsed
10
+ }.freeze
11
+
12
+ module_function
13
+
14
+ def notify(data_import, event)
15
+ webhooks = resolve_webhooks(data_import)
16
+ return if webhooks.nil? || webhooks.empty?
17
+
18
+ event_sym = EVENT_MAP.fetch(event)
19
+ matching = webhooks.select { |w| w.events.include?(event_sym) }
20
+ return if matching.empty?
21
+
22
+ matching.each do |webhook|
23
+ payload = build_payload(data_import, event, webhook)
24
+ DataPorter::WebhookJob.perform_later(webhook.url, payload, webhook.headers.dup)
25
+ end
26
+ end
27
+
28
+ def resolve_webhooks(data_import)
29
+ data_import.target_class._webhooks
30
+ end
31
+
32
+ def build_payload(data_import, event, webhook)
33
+ data = default_payload(data_import, event)
34
+ data = webhook.payload.call(data_import, event, data) if webhook.payload
35
+ data.to_json
36
+ end
37
+
38
+ def default_payload(data_import, event)
39
+ {
40
+ "event" => event,
41
+ "timestamp" => Time.current.iso8601,
42
+ "import" => import_data(data_import, event)
43
+ }
44
+ end
45
+
46
+ def import_data(data_import, event)
47
+ base = base_import_data(data_import)
48
+ merge_event_data(base, data_import, event)
49
+ end
50
+
51
+ def base_import_data(data_import)
52
+ {
53
+ "id" => data_import.id,
54
+ "target_key" => data_import.target_key,
55
+ "source_type" => data_import.source_type
56
+ }
57
+ end
58
+
59
+ def merge_event_data(base, data_import, event)
60
+ case event
61
+ when "import.completed" then merge_completed(base, data_import)
62
+ when "import.failed" then merge_failed(base, data_import)
63
+ when "import.parsed" then merge_parsed(base, data_import)
64
+ when "import.started" then merge_started(base, data_import)
65
+ else base
66
+ end
67
+ end
68
+
69
+ def merge_completed(base, data_import)
70
+ report = data_import.report
71
+ base.merge(
72
+ "imported_count" => report&.imported_count.to_i,
73
+ "errored_count" => report&.errored_count.to_i
74
+ )
75
+ end
76
+
77
+ def merge_failed(base, data_import)
78
+ message = first_error_message(data_import.report)
79
+ base.merge("error_message" => message)
80
+ end
81
+
82
+ def first_error_message(report)
83
+ errors = report&.error_reports
84
+ errors&.first&.message
85
+ end
86
+
87
+ def merge_parsed(base, data_import)
88
+ report = data_import.report
89
+ base.merge(
90
+ "records_count" => report&.records_count.to_i,
91
+ "complete_count" => report&.complete_count.to_i,
92
+ "partial_count" => report&.partial_count.to_i
93
+ )
94
+ end
95
+
96
+ def merge_started(base, data_import)
97
+ base.merge("records_count" => data_import.records.size)
98
+ end
99
+ end
100
+ end
data/lib/data_porter.rb CHANGED
@@ -9,6 +9,7 @@ end
9
9
  require_relative "data_porter/version"
10
10
  require_relative "data_porter/configuration"
11
11
  require_relative "data_porter/type_validator"
12
+ require_relative "data_porter/column_transformer"
12
13
  require_relative "data_porter/store_models/error"
13
14
  require_relative "data_porter/store_models/report"
14
15
  require_relative "data_porter/store_models/import_record"
@@ -17,6 +18,7 @@ require_relative "data_porter/registry"
17
18
  require_relative "data_porter/sources"
18
19
  require_relative "data_porter/record_validator"
19
20
  require_relative "data_porter/broadcaster"
21
+ require_relative "data_porter/webhook_notifier"
20
22
  require_relative "data_porter/orchestrator"
21
23
  require_relative "data_porter/rejects_csv_builder"
22
24
  require_relative "data_porter/components"
@@ -36,4 +36,9 @@ DataPorter.configure do |config|
36
36
  # Auto-purge completed/failed imports older than this duration.
37
37
  # Set to nil to disable auto-purge. Run `rake data_porter:purge` manually or via cron.
38
38
  # config.purge_after = 60.days
39
+
40
+ # HMAC-SHA256 secret for signing webhook payloads.
41
+ # When set, every webhook request includes an X-DataPorter-Signature header.
42
+ # Set to nil to disable signing (default).
43
+ # config.webhook_secret = ENV["DATA_PORTER_WEBHOOK_SECRET"]
39
44
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module DataPorter
6
+ module Generators
7
+ class ViewsGenerator < Rails::Generators::Base
8
+ SCOPES = %w[imports mapping_templates layout].freeze
9
+
10
+ source_root DataPorter::Engine.root.join("app/views")
11
+
12
+ argument :scope, type: :string, required: false, default: nil
13
+
14
+ def copy_views
15
+ validate_scope! if scope
16
+ scopes = scope ? [scope] : SCOPES
17
+
18
+ scopes.each { |s| send(:"copy_#{s}") }
19
+ end
20
+
21
+ private
22
+
23
+ def copy_imports
24
+ directory "data_porter/imports", "app/views/data_porter/imports"
25
+ end
26
+
27
+ def copy_mapping_templates
28
+ directory "data_porter/mapping_templates", "app/views/data_porter/mapping_templates"
29
+ end
30
+
31
+ def copy_layout
32
+ layout_src = DataPorter::Engine.root.join("app/views/layouts/data_porter/application.html.erb")
33
+ copy_file layout_src, "app/views/layouts/data_porter/application.html.erb"
34
+ end
35
+
36
+ def validate_scope!
37
+ return if SCOPES.include?(scope)
38
+
39
+ raise Thor::Error, "Unknown scope '#{scope}'. Valid scopes: #{SCOPES.join(", ")}"
40
+ end
41
+ end
42
+ end
43
+ 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: 2.1.1
4
+ version: 2.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seryl Lounis
@@ -106,12 +106,14 @@ files:
106
106
  - app/assets/javascripts/data_porter/progress_controller.js
107
107
  - app/assets/javascripts/data_porter/stimulus.min.js
108
108
  - app/assets/javascripts/data_porter/template_form_controller.js
109
+ - app/assets/javascripts/data_porter/theme_controller.js
109
110
  - app/assets/javascripts/data_porter/turbo.min.js
110
111
  - app/assets/stylesheets/data_porter/alerts.css
111
112
  - app/assets/stylesheets/data_porter/application.css
112
113
  - app/assets/stylesheets/data_porter/badges.css
113
114
  - app/assets/stylesheets/data_porter/base.css
114
115
  - app/assets/stylesheets/data_porter/cards.css
116
+ - app/assets/stylesheets/data_porter/dark.css
115
117
  - app/assets/stylesheets/data_porter/layout.css
116
118
  - app/assets/stylesheets/data_porter/mapping.css
117
119
  - app/assets/stylesheets/data_porter/modal.css
@@ -129,6 +131,7 @@ files:
129
131
  - app/jobs/data_porter/extract_headers_job.rb
130
132
  - app/jobs/data_porter/import_job.rb
131
133
  - app/jobs/data_porter/parse_job.rb
134
+ - app/jobs/data_porter/webhook_job.rb
132
135
  - app/models/data_porter/data_import.rb
133
136
  - app/models/data_porter/mapping_template.rb
134
137
  - app/views/data_porter/imports/index.html.erb
@@ -144,6 +147,7 @@ files:
144
147
  - config/routes.rb
145
148
  - lib/data_porter.rb
146
149
  - lib/data_porter/broadcaster.rb
150
+ - lib/data_porter/column_transformer.rb
147
151
  - lib/data_porter/components.rb
148
152
  - lib/data_porter/components/base.rb
149
153
  - lib/data_porter/components/mapping/column_row.rb
@@ -160,6 +164,7 @@ files:
160
164
  - lib/data_porter/dsl/api_config.rb
161
165
  - lib/data_porter/dsl/column.rb
162
166
  - lib/data_porter/dsl/param.rb
167
+ - lib/data_porter/dsl/webhook.rb
163
168
  - lib/data_porter/engine.rb
164
169
  - lib/data_porter/orchestrator.rb
165
170
  - lib/data_porter/orchestrator/dry_runner.rb
@@ -180,6 +185,7 @@ files:
180
185
  - lib/data_porter/target.rb
181
186
  - lib/data_porter/type_validator.rb
182
187
  - lib/data_porter/version.rb
188
+ - lib/data_porter/webhook_notifier.rb
183
189
  - lib/generators/data_porter/install/install_generator.rb
184
190
  - lib/generators/data_porter/install/templates/create_data_porter_imports.rb.erb
185
191
  - lib/generators/data_porter/install/templates/create_data_porter_mapping_templates.rb.erb
@@ -187,6 +193,7 @@ files:
187
193
  - lib/generators/data_porter/locale/locale_generator.rb
188
194
  - lib/generators/data_porter/target/target_generator.rb
189
195
  - lib/generators/data_porter/target/templates/target.rb.tt
196
+ - lib/generators/data_porter/views/views_generator.rb
190
197
  - lib/tasks/data_porter.rake
191
198
  - mkdocs.yml
192
199
  - sig/data_porter.rbs