data_porter 0.2.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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +73 -0
  3. data/README.md +60 -393
  4. data/ROADMAP.md +30 -12
  5. data/app/assets/javascripts/data_porter/stimulus.min.js +2 -0
  6. data/app/assets/javascripts/data_porter/turbo.min.js +29 -0
  7. data/app/assets/stylesheets/data_porter/alerts.css +83 -0
  8. data/app/assets/stylesheets/data_porter/application.css +12 -646
  9. data/app/assets/stylesheets/data_porter/badges.css +73 -0
  10. data/app/assets/stylesheets/data_porter/base.css +56 -0
  11. data/app/assets/stylesheets/data_porter/cards.css +60 -0
  12. data/app/assets/stylesheets/data_porter/layout.css +147 -0
  13. data/app/assets/stylesheets/data_porter/mapping.css +79 -0
  14. data/app/assets/stylesheets/data_porter/modal.css +49 -0
  15. data/app/assets/stylesheets/data_porter/preview.css +24 -0
  16. data/app/assets/stylesheets/data_porter/progress.css +62 -0
  17. data/app/assets/stylesheets/data_porter/table.css +51 -0
  18. data/app/controllers/data_porter/imports_controller.rb +96 -10
  19. data/app/controllers/data_porter/mapping_templates_controller.rb +85 -0
  20. data/app/javascript/data_porter/mapping_controller.js +86 -0
  21. data/app/javascript/data_porter/progress_controller.js +29 -20
  22. data/app/javascript/data_porter/template_form_controller.js +46 -0
  23. data/app/jobs/data_porter/extract_headers_job.rb +12 -0
  24. data/app/models/data_porter/data_import.rb +12 -1
  25. data/app/models/data_porter/mapping_template.rb +15 -0
  26. data/app/views/data_porter/imports/index.html.erb +38 -9
  27. data/app/views/data_porter/imports/new.html.erb +31 -4
  28. data/app/views/data_porter/imports/show.html.erb +74 -17
  29. data/app/views/data_porter/mapping_templates/_form.html.erb +40 -0
  30. data/app/views/data_porter/mapping_templates/edit.html.erb +11 -0
  31. data/app/views/data_porter/mapping_templates/index.html.erb +42 -0
  32. data/app/views/data_porter/mapping_templates/new.html.erb +11 -0
  33. data/app/views/layouts/data_porter/application.html.erb +168 -0
  34. data/config/routes.rb +5 -1
  35. data/docs/CONFIGURATION.md +81 -0
  36. data/docs/MAPPING.md +44 -0
  37. data/docs/SOURCES.md +94 -0
  38. data/docs/TARGETS.md +176 -0
  39. data/docs/screenshots/mapping.jpg +0 -0
  40. data/lib/data_porter/components/mapping/column_row.rb +52 -0
  41. data/lib/data_porter/components/mapping/form.rb +127 -0
  42. data/lib/data_porter/components/mapping/template_select.rb +35 -0
  43. data/lib/data_porter/components/preview/results_summary.rb +60 -0
  44. data/lib/data_porter/components/preview/summary_cards.rb +32 -0
  45. data/lib/data_porter/components/preview/table.rb +56 -0
  46. data/lib/data_porter/components/progress/bar.rb +47 -0
  47. data/lib/data_porter/components/shared/failure_alert.rb +22 -0
  48. data/lib/data_porter/components/shared/status_badge.rb +18 -0
  49. data/lib/data_porter/components.rb +9 -6
  50. data/lib/data_porter/configuration.rb +3 -1
  51. data/lib/data_porter/engine.rb +7 -1
  52. data/lib/data_porter/orchestrator.rb +35 -2
  53. data/lib/data_porter/registry.rb +6 -1
  54. data/lib/data_porter/sources/base.rb +18 -3
  55. data/lib/data_porter/sources/csv.rb +5 -0
  56. data/lib/data_porter/sources/xlsx.rb +8 -0
  57. data/lib/data_porter/version.rb +1 -1
  58. data/lib/generators/data_porter/install/install_generator.rb +4 -0
  59. data/lib/generators/data_porter/install/templates/create_data_porter_mapping_templates.rb.erb +16 -0
  60. data/lib/generators/data_porter/install/templates/initializer.rb +5 -1
  61. data/lib/generators/data_porter/target/target_generator.rb +5 -0
  62. data/lib/generators/data_porter/target/templates/target.rb.tt +1 -1
  63. data/lib/tasks/data_porter.rake +9 -0
  64. metadata +62 -39
  65. data/lib/data_porter/components/failure_alert.rb +0 -20
  66. data/lib/data_porter/components/preview_table.rb +0 -54
  67. data/lib/data_porter/components/progress_bar.rb +0 -33
  68. data/lib/data_porter/components/results_summary.rb +0 -19
  69. data/lib/data_porter/components/status_badge.rb +0 -16
  70. data/lib/data_porter/components/summary_cards.rb +0 -30
@@ -0,0 +1,56 @@
1
+ :root {
2
+ --dp-primary: #4f46e5;
3
+ --dp-primary-hover: #4338ca;
4
+ --dp-primary-light: #eef2ff;
5
+ --dp-success: #059669;
6
+ --dp-success-light: #ecfdf5;
7
+ --dp-success-border: #a7f3d0;
8
+ --dp-warning: #d97706;
9
+ --dp-warning-light: #fffbeb;
10
+ --dp-warning-border: #fde68a;
11
+ --dp-danger: #dc2626;
12
+ --dp-danger-hover: #b91c1c;
13
+ --dp-danger-light: #fef2f2;
14
+ --dp-danger-border: #fecaca;
15
+ --dp-info: #2563eb;
16
+ --dp-info-light: #eff6ff;
17
+ --dp-info-border: #bfdbfe;
18
+ --dp-purple: #7c3aed;
19
+ --dp-purple-light: #f5f3ff;
20
+ --dp-purple-border: #ddd6fe;
21
+ --dp-gray-50: #f9fafb;
22
+ --dp-gray-100: #f3f4f6;
23
+ --dp-gray-200: #e5e7eb;
24
+ --dp-gray-300: #d1d5db;
25
+ --dp-gray-400: #9ca3af;
26
+ --dp-gray-500: #6b7280;
27
+ --dp-gray-600: #4b5563;
28
+ --dp-gray-700: #374151;
29
+ --dp-gray-800: #1f2937;
30
+ --dp-gray-900: #111827;
31
+ --dp-radius-sm: 0.375rem;
32
+ --dp-radius-md: 0.5rem;
33
+ --dp-radius-lg: 0.75rem;
34
+ --dp-radius-xl: 1rem;
35
+ --dp-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
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
+ --dp-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
38
+ }
39
+
40
+ .data-porter {
41
+ font-family: "Inter", system-ui, -apple-system, sans-serif;
42
+ color: var(--dp-gray-800);
43
+ max-width: 1100px;
44
+ margin: 2rem auto;
45
+ padding: 0 1.5rem;
46
+ line-height: 1.6;
47
+ -webkit-font-smoothing: antialiased;
48
+ }
49
+
50
+ .dp-title {
51
+ font-size: 1.75rem;
52
+ font-weight: 700;
53
+ margin: 0;
54
+ letter-spacing: -0.025em;
55
+ color: var(--dp-gray-900);
56
+ }
@@ -0,0 +1,60 @@
1
+ .dp-summary-cards {
2
+ display: grid;
3
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
4
+ gap: 1rem;
5
+ margin-bottom: 2rem;
6
+ }
7
+
8
+ .dp-card {
9
+ padding: 1.25rem;
10
+ border-radius: var(--dp-radius-lg);
11
+ border: 1px solid var(--dp-gray-200);
12
+ text-align: center;
13
+ background: white;
14
+ box-shadow: var(--dp-shadow-sm);
15
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
16
+ }
17
+
18
+ .dp-card:hover {
19
+ transform: translateY(-1px);
20
+ box-shadow: var(--dp-shadow-md);
21
+ }
22
+
23
+ .dp-card strong {
24
+ display: block;
25
+ font-size: 2rem;
26
+ font-weight: 700;
27
+ margin-bottom: 0.25rem;
28
+ letter-spacing: -0.025em;
29
+ }
30
+
31
+ .dp-card span {
32
+ font-size: 0.8rem;
33
+ font-weight: 500;
34
+ text-transform: uppercase;
35
+ letter-spacing: 0.05em;
36
+ }
37
+
38
+ .dp-card--complete {
39
+ border-color: var(--dp-success-border);
40
+ background: var(--dp-success-light);
41
+ }
42
+ .dp-card--complete strong { color: var(--dp-success); }
43
+
44
+ .dp-card--partial {
45
+ border-color: var(--dp-warning-border);
46
+ background: var(--dp-warning-light);
47
+ }
48
+ .dp-card--partial strong { color: var(--dp-warning); }
49
+
50
+ .dp-card--missing {
51
+ border-color: var(--dp-danger-border);
52
+ background: var(--dp-danger-light);
53
+ }
54
+ .dp-card--missing strong { color: var(--dp-danger); }
55
+
56
+ .dp-card--duplicate {
57
+ border-color: var(--dp-gray-300);
58
+ background: var(--dp-gray-50);
59
+ }
60
+ .dp-card--duplicate strong { color: var(--dp-gray-500); }
@@ -0,0 +1,147 @@
1
+ .dp-header {
2
+ display: flex;
3
+ align-items: center;
4
+ justify-content: space-between;
5
+ gap: 1rem;
6
+ margin-bottom: 2rem;
7
+ padding-bottom: 1.5rem;
8
+ border-bottom: 1px solid var(--dp-gray-200);
9
+ }
10
+
11
+ .dp-header__actions { display: flex; align-items: center; gap: 0.75rem; }
12
+
13
+ .dp-form {
14
+ max-width: 520px;
15
+ background: white;
16
+ padding: 2rem;
17
+ border: 1px solid var(--dp-gray-200);
18
+ border-radius: var(--dp-radius-lg);
19
+ box-shadow: var(--dp-shadow-md);
20
+ }
21
+
22
+ .dp-field { margin-bottom: 1.5rem; }
23
+
24
+ .dp-label {
25
+ display: block;
26
+ font-weight: 600;
27
+ margin-bottom: 0.5rem;
28
+ font-size: 0.875rem;
29
+ color: var(--dp-gray-700);
30
+ }
31
+
32
+ .dp-select, .dp-file-input {
33
+ display: block;
34
+ width: 100%;
35
+ padding: 0.625rem 0.875rem;
36
+ border: 1px solid var(--dp-gray-300);
37
+ border-radius: var(--dp-radius-md);
38
+ font-size: 0.9375rem;
39
+ background: white;
40
+ color: var(--dp-gray-800);
41
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
42
+ appearance: none;
43
+ }
44
+
45
+ .dp-select {
46
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
47
+ background-position: right 0.5rem center;
48
+ background-repeat: no-repeat;
49
+ background-size: 1.5em 1.5em;
50
+ padding-right: 2.5rem;
51
+ }
52
+
53
+ .dp-select:focus, .dp-file-input:focus {
54
+ outline: none;
55
+ border-color: var(--dp-primary);
56
+ box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15);
57
+ }
58
+
59
+ .dp-actions { display: flex; gap: 0.75rem; margin: 2rem 0; }
60
+
61
+ .dp-btn {
62
+ display: inline-flex;
63
+ align-items: center;
64
+ justify-content: center;
65
+ padding: 0.625rem 1.25rem;
66
+ border-radius: var(--dp-radius-md);
67
+ font-size: 0.875rem;
68
+ font-weight: 600;
69
+ text-decoration: none;
70
+ border: 1px solid transparent;
71
+ cursor: pointer;
72
+ transition: all 0.15s ease;
73
+ line-height: 1.25;
74
+ }
75
+
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
+ }
94
+
95
+ .dp-btn--primary { background: var(--dp-primary); color: white; box-shadow: var(--dp-shadow-sm); }
96
+ .dp-btn--primary:hover { background: var(--dp-primary-hover); box-shadow: var(--dp-shadow-md); }
97
+
98
+ .dp-btn--secondary { background: white; color: var(--dp-gray-700); border-color: var(--dp-gray-300); box-shadow: var(--dp-shadow-sm); }
99
+ .dp-btn--secondary:hover { background: var(--dp-gray-50); border-color: var(--dp-gray-400); }
100
+
101
+ .dp-btn--danger { background: var(--dp-danger); color: white; box-shadow: var(--dp-shadow-sm); }
102
+ .dp-btn--danger:hover { background: var(--dp-danger-hover); box-shadow: var(--dp-shadow-md); }
103
+
104
+ .dp-btn--sm { padding: 0.375rem 0.75rem; font-size: 0.8125rem; }
105
+
106
+ .dp-link { color: var(--dp-primary); text-decoration: none; font-weight: 500; transition: color 0.15s ease; }
107
+ .dp-link:hover { color: var(--dp-primary-hover); text-decoration: underline; }
108
+
109
+ .dp-nav { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--dp-gray-200); }
110
+ .dp-empty { text-align: center; padding: 3rem 1rem; color: var(--dp-gray-400); font-size: 0.9375rem; }
111
+
112
+ .dp-empty-state {
113
+ text-align: center;
114
+ padding: 4rem 2rem;
115
+ background: white;
116
+ border: 2px dashed var(--dp-gray-300);
117
+ border-radius: var(--dp-radius-xl);
118
+ }
119
+
120
+ .dp-empty-state__icon { font-size: 3rem; margin-bottom: 1rem; opacity: 0.6; }
121
+ .dp-empty-state__text { font-size: 1.125rem; color: var(--dp-gray-500); margin: 0 0 1.5rem; }
122
+
123
+ .dp-import-details {
124
+ background: white;
125
+ padding: 1.5rem;
126
+ border: 1px solid var(--dp-gray-200);
127
+ border-radius: var(--dp-radius-lg);
128
+ margin-bottom: 2rem;
129
+ }
130
+
131
+ .dp-details-grid {
132
+ display: grid;
133
+ grid-template-columns: auto 1fr;
134
+ gap: 0.5rem 1.5rem;
135
+ margin: 0;
136
+ font-size: 0.9375rem;
137
+ }
138
+
139
+ .dp-details-grid dt {
140
+ font-weight: 600;
141
+ color: var(--dp-gray-500);
142
+ }
143
+
144
+ .dp-details-grid dd {
145
+ margin: 0;
146
+ color: var(--dp-gray-800);
147
+ }
@@ -0,0 +1,79 @@
1
+ .dp-mapping-form {
2
+ max-width: 700px;
3
+ background: white;
4
+ padding: 2rem;
5
+ border: 1px solid var(--dp-gray-200);
6
+ border-radius: var(--dp-radius-lg);
7
+ box-shadow: var(--dp-shadow-md);
8
+ }
9
+
10
+ .dp-mapping-template {
11
+ margin-bottom: 1.5rem;
12
+ }
13
+
14
+ .dp-mapping-rows {
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: 0.75rem;
18
+ }
19
+
20
+ .dp-mapping-row {
21
+ display: flex;
22
+ align-items: center;
23
+ gap: 1rem;
24
+ padding: 0.75rem 1rem;
25
+ background: var(--dp-gray-50);
26
+ border: 1px solid var(--dp-gray-200);
27
+ border-radius: var(--dp-radius-md);
28
+ transition: border-color 0.15s ease;
29
+ }
30
+
31
+ .dp-mapping-row:hover {
32
+ border-color: var(--dp-gray-300);
33
+ }
34
+
35
+ .dp-mapping-row__header {
36
+ flex: 1;
37
+ font-weight: 600;
38
+ font-size: 0.875rem;
39
+ color: var(--dp-gray-800);
40
+ white-space: nowrap;
41
+ overflow: hidden;
42
+ text-overflow: ellipsis;
43
+ }
44
+
45
+ .dp-mapping-row__arrow {
46
+ color: var(--dp-gray-400);
47
+ font-size: 1.125rem;
48
+ flex-shrink: 0;
49
+ }
50
+
51
+ .dp-mapping-row__select {
52
+ flex: 1;
53
+ }
54
+
55
+ .dp-mapping-row--duplicate {
56
+ border-color: #f59e0b;
57
+ background: #fffbeb;
58
+ }
59
+
60
+ .dp-mapping-required-warning,
61
+ .dp-mapping-duplicate-warning {
62
+ padding: 0.75rem 1rem;
63
+ margin-bottom: 1rem;
64
+ border-radius: var(--dp-radius-md);
65
+ font-size: 0.875rem;
66
+ font-weight: 500;
67
+ }
68
+
69
+ .dp-mapping-required-warning {
70
+ background: #fef2f2;
71
+ border: 1px solid #fca5a5;
72
+ color: #991b1b;
73
+ }
74
+
75
+ .dp-mapping-duplicate-warning {
76
+ background: #fffbeb;
77
+ border: 1px solid #fcd34d;
78
+ color: #92400e;
79
+ }
@@ -0,0 +1,49 @@
1
+ .dp-modal { display: none; position: fixed; inset: 0; z-index: 9999; align-items: center; justify-content: center; }
2
+ .dp-modal--open { display: flex; }
3
+ .dp-modal__backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.4); backdrop-filter: blur(4px); }
4
+ .dp-modal__content {
5
+ position: relative;
6
+ background: white;
7
+ border-radius: var(--dp-radius-xl);
8
+ box-shadow: var(--dp-shadow-lg), 0 25px 50px -12px rgba(0,0,0,0.15);
9
+ width: 100%;
10
+ max-width: 520px;
11
+ max-height: 90vh;
12
+ overflow-y: auto;
13
+ animation: dp-modal-in 0.2s ease-out;
14
+ }
15
+
16
+ @keyframes dp-modal-in {
17
+ from { opacity: 0; transform: scale(0.95) translateY(10px); }
18
+ to { opacity: 1; transform: scale(1) translateY(0); }
19
+ }
20
+
21
+ .dp-modal__header { display: flex; align-items: center; justify-content: space-between; padding: 1.5rem 1.5rem 0; }
22
+ .dp-modal__title { font-size: 1.25rem; font-weight: 700; margin: 0; color: var(--dp-gray-900); }
23
+ .dp-modal__close { background: none; border: none; font-size: 1.5rem; color: var(--dp-gray-400); cursor: pointer; padding: 0.25rem; line-height: 1; border-radius: var(--dp-radius-sm); transition: color 0.15s ease, background 0.15s ease; }
24
+ .dp-modal__close:hover { color: var(--dp-gray-700); background: var(--dp-gray-100); }
25
+ .dp-modal__body { padding: 1.5rem; }
26
+ .dp-modal__footer { display: flex; gap: 0.75rem; justify-content: flex-end; padding-top: 0.5rem; border-top: 1px solid var(--dp-gray-100); margin-top: 0.5rem; padding-top: 1.5rem; }
27
+
28
+ .dp-dropzone {
29
+ display: flex;
30
+ flex-direction: column;
31
+ align-items: center;
32
+ justify-content: center;
33
+ padding: 2rem 1.5rem;
34
+ border: 2px dashed var(--dp-gray-300);
35
+ border-radius: var(--dp-radius-lg);
36
+ cursor: pointer;
37
+ transition: border-color 0.15s ease, background 0.15s ease;
38
+ text-align: center;
39
+ }
40
+
41
+ .dp-dropzone:hover, .dp-dropzone--dragover { border-color: var(--dp-primary); background: var(--dp-primary-light); }
42
+ .dp-dropzone--has-file { border-color: var(--dp-success); background: var(--dp-success-light); border-style: solid; }
43
+ .dp-dropzone__input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
44
+ .dp-dropzone__content { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
45
+ .dp-dropzone__icon { font-size: 2rem; opacity: 0.5; }
46
+ .dp-dropzone__text { font-size: 0.9375rem; color: var(--dp-gray-600); }
47
+ .dp-dropzone__text strong { color: var(--dp-primary); }
48
+ .dp-dropzone__hint { font-size: 0.8rem; color: var(--dp-gray-400); }
49
+ .dp-dropzone__selected { font-size: 0.875rem; font-weight: 600; color: var(--dp-success); margin-top: 0.5rem; }
@@ -0,0 +1,24 @@
1
+ .dp-preview-table {
2
+ overflow-x: auto;
3
+ margin-bottom: 2rem;
4
+ border-radius: var(--dp-radius-lg);
5
+ border: 1px solid var(--dp-gray-200);
6
+ box-shadow: var(--dp-shadow-sm);
7
+ }
8
+
9
+ .dp-preview-table .dp-table {
10
+ margin-bottom: 0;
11
+ border: none;
12
+ border-radius: 0;
13
+ box-shadow: none;
14
+ }
15
+
16
+ .dp-row--complete { background: var(--dp-success-light); }
17
+ .dp-row--partial { background: var(--dp-warning-light); }
18
+ .dp-row--missing { background: var(--dp-danger-light); }
19
+
20
+ .dp-errors {
21
+ color: var(--dp-danger);
22
+ font-size: 0.8rem;
23
+ font-weight: 500;
24
+ }
@@ -0,0 +1,62 @@
1
+ .dp-progress-container {
2
+ margin: 2rem 0;
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;
23
+ overflow: hidden;
24
+ height: 1.5rem;
25
+ border: 1px solid var(--dp-gray-200);
26
+ }
27
+
28
+ .dp-progress-bar {
29
+ height: 100%;
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);
35
+ display: flex;
36
+ align-items: center;
37
+ justify-content: center;
38
+ color: white;
39
+ font-size: 0.6875rem;
40
+ font-weight: 700;
41
+ min-width: 2.25rem;
42
+ position: relative;
43
+ overflow: hidden;
44
+ }
45
+
46
+ .dp-progress-bar::after {
47
+ content: "";
48
+ position: absolute;
49
+ inset: 0;
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%; }
57
+ }
58
+
59
+ @keyframes dp-shimmer {
60
+ 0% { transform: translateX(-100%); }
61
+ 100% { transform: translateX(100%); }
62
+ }
@@ -0,0 +1,51 @@
1
+ .dp-table {
2
+ width: 100%;
3
+ border-collapse: separate;
4
+ border-spacing: 0;
5
+ margin-bottom: 1.5rem;
6
+ background: white;
7
+ border: 1px solid var(--dp-gray-200);
8
+ border-radius: var(--dp-radius-lg);
9
+ overflow: hidden;
10
+ box-shadow: var(--dp-shadow-sm);
11
+ }
12
+
13
+ .dp-table th,
14
+ .dp-table td {
15
+ padding: 0.75rem 1rem;
16
+ text-align: left;
17
+ }
18
+
19
+ .dp-table th {
20
+ font-weight: 600;
21
+ font-size: 0.75rem;
22
+ text-transform: uppercase;
23
+ letter-spacing: 0.05em;
24
+ color: var(--dp-gray-500);
25
+ background: var(--dp-gray-50);
26
+ border-bottom: 1px solid var(--dp-gray-200);
27
+ }
28
+
29
+ .dp-table td {
30
+ font-size: 0.875rem;
31
+ color: var(--dp-gray-700);
32
+ border-bottom: 1px solid var(--dp-gray-100);
33
+ }
34
+
35
+ .dp-table tbody tr:last-child td {
36
+ border-bottom: none;
37
+ }
38
+
39
+ .dp-table tbody tr {
40
+ transition: background-color 0.15s ease;
41
+ }
42
+
43
+ .dp-table tbody tr:hover {
44
+ background: var(--dp-gray-50);
45
+ }
46
+
47
+ .dp-table__actions {
48
+ display: flex;
49
+ align-items: center;
50
+ gap: 0.5rem;
51
+ }
@@ -2,29 +2,27 @@
2
2
 
3
3
  module DataPorter
4
4
  class ImportsController < DataPorter.configuration.parent_controller.constantize
5
- before_action :set_import, only: %i[show parse confirm cancel dry_run]
5
+ layout "data_porter/application"
6
+
7
+ before_action :set_import, only: %i[show parse confirm cancel dry_run update_mapping status destroy]
8
+ before_action :load_targets, only: %i[index new create]
6
9
 
7
10
  def index
8
11
  @imports = DataPorter::DataImport.order(created_at: :desc)
9
- @targets = DataPorter::Registry.available
10
12
  end
11
13
 
12
14
  def new
13
15
  @import = DataPorter::DataImport.new
14
- @targets = DataPorter::Registry.available
15
16
  end
16
17
 
17
18
  def create
18
- @import = DataPorter::DataImport.new(import_params)
19
- @import.user = current_user if respond_to?(:current_user, true)
20
- @import.status = :pending
19
+ build_import
21
20
 
22
- if @import.save
23
- DataPorter::ParseJob.perform_later(@import.id)
21
+ if valid_source_for_target? && valid_file_presence? && @import.save
22
+ enqueue_after_create
24
23
  redirect_to import_path(@import)
25
24
  else
26
- @targets = DataPorter::Registry.available
27
- render :new
25
+ render :new, status: :unprocessable_entity
28
26
  end
29
27
  end
30
28
 
@@ -32,6 +30,7 @@ module DataPorter
32
30
  @target = @import.target_class
33
31
  @records = @import.records
34
32
  @grouped = @records.group_by(&:status)
33
+ load_mapping_data if @import.mapping?
35
34
  end
36
35
 
37
36
  def parse
@@ -40,7 +39,15 @@ module DataPorter
40
39
  redirect_to import_path(@import)
41
40
  end
42
41
 
42
+ def update_mapping
43
+ save_column_mapping
44
+ save_template_if_requested
45
+ DataPorter::ParseJob.perform_later(@import.id)
46
+ redirect_to import_path(@import)
47
+ end
48
+
43
49
  def confirm
50
+ @import.update!(status: :pending)
44
51
  DataPorter::ImportJob.perform_later(@import.id)
45
52
  redirect_to import_path(@import)
46
53
  end
@@ -51,18 +58,97 @@ module DataPorter
51
58
  end
52
59
 
53
60
  def dry_run
61
+ @import.update!(status: :pending)
54
62
  DataPorter::DryRunJob.perform_later(@import.id)
55
63
  redirect_to import_path(@import)
56
64
  end
57
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
+
58
77
  private
59
78
 
60
79
  def set_import
61
80
  @import = DataPorter::DataImport.find(params[:id])
62
81
  end
63
82
 
83
+ def load_targets
84
+ @targets = DataPorter::Registry.available
85
+ end
86
+
87
+ def build_import
88
+ @import = DataPorter::DataImport.new(import_params)
89
+ @import.user = current_user if respond_to?(:current_user, true)
90
+ @import.status = :pending
91
+ end
92
+
64
93
  def import_params
65
94
  params.require(:data_import).permit(:target_key, :source_type, :file, config: {})
66
95
  end
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
+
106
+ def valid_file_presence?
107
+ return true unless %w[csv json xlsx].include?(@import.source_type)
108
+ return true if @import.file.attached?
109
+
110
+ @import.errors.add(:file, "must be attached for #{@import.source_type.upcase} imports")
111
+ false
112
+ end
113
+
114
+ def enqueue_after_create
115
+ if @import.file_based?
116
+ DataPorter::ExtractHeadersJob.perform_later(@import.id)
117
+ else
118
+ DataPorter::ParseJob.perform_later(@import.id)
119
+ end
120
+ end
121
+
122
+ def load_mapping_data
123
+ target = @import.target_class
124
+ columns = target._columns || []
125
+ @file_headers = @import.config["file_headers"] || []
126
+ @target_columns = columns.map { |c| [c.label, c.name.to_s, c.required] }
127
+ @default_mapping = (target._csv_mappings || {}).transform_values(&:to_s)
128
+ @templates = load_templates
129
+ end
130
+
131
+ def load_templates
132
+ return [] unless defined?(DataPorter::MappingTemplate)
133
+
134
+ DataPorter::MappingTemplate.for_target(@import.target_key)
135
+ end
136
+
137
+ def save_column_mapping
138
+ mapping = params.require(:column_mapping).permit!.to_h
139
+ merged = (@import.config || {}).merge("column_mapping" => mapping)
140
+ @import.update!(config: merged, status: :pending)
141
+ end
142
+
143
+ def save_template_if_requested
144
+ return unless params[:save_template] == "1"
145
+ return unless defined?(DataPorter::MappingTemplate)
146
+
147
+ mapping = params.require(:column_mapping).permit!.to_h
148
+ DataPorter::MappingTemplate.find_or_initialize_by(
149
+ target_key: @import.target_key,
150
+ name: params[:template_name].presence || "Default"
151
+ ).update!(mapping: mapping)
152
+ end
67
153
  end
68
154
  end