data_porter 0.1.0 → 0.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 (168) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/README.md +63 -386
  4. data/ROADMAP.md +89 -0
  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 +25 -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 +128 -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 +37 -0
  17. data/app/assets/stylesheets/data_porter/table.css +45 -0
  18. data/app/controllers/data_porter/imports_controller.rb +74 -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 +1 -1
  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 +8 -2
  25. data/app/models/data_porter/mapping_template.rb +15 -0
  26. data/app/views/data_porter/imports/index.html.erb +9 -8
  27. data/app/views/data_porter/imports/new.html.erb +10 -4
  28. data/app/views/data_porter/imports/show.html.erb +41 -13
  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 +162 -0
  34. data/config/routes.rb +3 -0
  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 +21 -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 +35 -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 +1 -1
  51. data/lib/data_porter/engine.rb +7 -1
  52. data/lib/data_porter/orchestrator.rb +21 -1
  53. data/lib/data_porter/sources/base.rb +18 -3
  54. data/lib/data_porter/sources/csv.rb +5 -0
  55. data/lib/data_porter/sources/xlsx.rb +76 -0
  56. data/lib/data_porter/sources.rb +3 -1
  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 +1 -1
  61. metadata +72 -135
  62. data/.claude/commands/blog-status.md +0 -10
  63. data/.claude/commands/blog.md +0 -109
  64. data/.claude/commands/task-done.md +0 -27
  65. data/.claude/commands/tm/add-dependency.md +0 -58
  66. data/.claude/commands/tm/add-subtask.md +0 -79
  67. data/.claude/commands/tm/add-task.md +0 -81
  68. data/.claude/commands/tm/analyze-complexity.md +0 -124
  69. data/.claude/commands/tm/analyze-project.md +0 -100
  70. data/.claude/commands/tm/auto-implement-tasks.md +0 -100
  71. data/.claude/commands/tm/command-pipeline.md +0 -80
  72. data/.claude/commands/tm/complexity-report.md +0 -120
  73. data/.claude/commands/tm/convert-task-to-subtask.md +0 -74
  74. data/.claude/commands/tm/expand-all-tasks.md +0 -52
  75. data/.claude/commands/tm/expand-task.md +0 -52
  76. data/.claude/commands/tm/fix-dependencies.md +0 -82
  77. data/.claude/commands/tm/help.md +0 -101
  78. data/.claude/commands/tm/init-project-quick.md +0 -49
  79. data/.claude/commands/tm/init-project.md +0 -53
  80. data/.claude/commands/tm/install-taskmaster.md +0 -118
  81. data/.claude/commands/tm/learn.md +0 -106
  82. data/.claude/commands/tm/list-tasks-by-status.md +0 -42
  83. data/.claude/commands/tm/list-tasks-with-subtasks.md +0 -30
  84. data/.claude/commands/tm/list-tasks.md +0 -46
  85. data/.claude/commands/tm/next-task.md +0 -69
  86. data/.claude/commands/tm/parse-prd-with-research.md +0 -51
  87. data/.claude/commands/tm/parse-prd.md +0 -52
  88. data/.claude/commands/tm/project-status.md +0 -67
  89. data/.claude/commands/tm/quick-install-taskmaster.md +0 -23
  90. data/.claude/commands/tm/remove-all-subtasks.md +0 -94
  91. data/.claude/commands/tm/remove-dependency.md +0 -65
  92. data/.claude/commands/tm/remove-subtask.md +0 -87
  93. data/.claude/commands/tm/remove-subtasks.md +0 -89
  94. data/.claude/commands/tm/remove-task.md +0 -110
  95. data/.claude/commands/tm/setup-models.md +0 -52
  96. data/.claude/commands/tm/show-task.md +0 -85
  97. data/.claude/commands/tm/smart-workflow.md +0 -58
  98. data/.claude/commands/tm/sync-readme.md +0 -120
  99. data/.claude/commands/tm/tm-main.md +0 -147
  100. data/.claude/commands/tm/to-cancelled.md +0 -58
  101. data/.claude/commands/tm/to-deferred.md +0 -50
  102. data/.claude/commands/tm/to-done.md +0 -47
  103. data/.claude/commands/tm/to-in-progress.md +0 -39
  104. data/.claude/commands/tm/to-pending.md +0 -35
  105. data/.claude/commands/tm/to-review.md +0 -43
  106. data/.claude/commands/tm/update-single-task.md +0 -122
  107. data/.claude/commands/tm/update-task.md +0 -75
  108. data/.claude/commands/tm/update-tasks-from-id.md +0 -111
  109. data/.claude/commands/tm/validate-dependencies.md +0 -72
  110. data/.claude/commands/tm/view-models.md +0 -52
  111. data/.env.example +0 -12
  112. data/.mcp.json +0 -24
  113. data/.taskmaster/CLAUDE.md +0 -435
  114. data/.taskmaster/config.json +0 -44
  115. data/.taskmaster/docs/prd.txt +0 -2044
  116. data/.taskmaster/state.json +0 -6
  117. data/.taskmaster/tasks/task_001.md +0 -19
  118. data/.taskmaster/tasks/task_002.md +0 -19
  119. data/.taskmaster/tasks/task_003.md +0 -19
  120. data/.taskmaster/tasks/task_004.md +0 -19
  121. data/.taskmaster/tasks/task_005.md +0 -19
  122. data/.taskmaster/tasks/task_006.md +0 -19
  123. data/.taskmaster/tasks/task_007.md +0 -19
  124. data/.taskmaster/tasks/task_008.md +0 -19
  125. data/.taskmaster/tasks/task_009.md +0 -19
  126. data/.taskmaster/tasks/task_010.md +0 -19
  127. data/.taskmaster/tasks/task_011.md +0 -19
  128. data/.taskmaster/tasks/task_012.md +0 -19
  129. data/.taskmaster/tasks/task_013.md +0 -19
  130. data/.taskmaster/tasks/task_014.md +0 -19
  131. data/.taskmaster/tasks/task_015.md +0 -19
  132. data/.taskmaster/tasks/task_016.md +0 -19
  133. data/.taskmaster/tasks/task_017.md +0 -19
  134. data/.taskmaster/tasks/task_018.md +0 -19
  135. data/.taskmaster/tasks/task_019.md +0 -19
  136. data/.taskmaster/tasks/task_020.md +0 -19
  137. data/.taskmaster/tasks/tasks.json +0 -299
  138. data/.taskmaster/templates/example_prd.txt +0 -47
  139. data/.taskmaster/templates/example_prd_rpg.txt +0 -511
  140. data/CLAUDE.md +0 -65
  141. data/config/database.yml +0 -3
  142. data/docs/SPEC.md +0 -2012
  143. data/docs/UI.md +0 -32
  144. data/docs/blog/001-why-build-a-data-import-engine.md +0 -166
  145. data/docs/blog/002-scaffolding-a-rails-engine.md +0 -188
  146. data/docs/blog/003-configuration-dsl.md +0 -222
  147. data/docs/blog/004-store-model-jsonb.md +0 -237
  148. data/docs/blog/005-target-dsl.md +0 -284
  149. data/docs/blog/006-parsing-csv-sources.md +0 -300
  150. data/docs/blog/007-orchestrator.md +0 -247
  151. data/docs/blog/008-actioncable-stimulus.md +0 -376
  152. data/docs/blog/009-phlex-ui-components.md +0 -446
  153. data/docs/blog/010-controllers-routing.md +0 -374
  154. data/docs/blog/011-generators.md +0 -364
  155. data/docs/blog/012-json-api-sources.md +0 -323
  156. data/docs/blog/013-testing-rails-engine.md +0 -618
  157. data/docs/blog/014-dry-run.md +0 -307
  158. data/docs/blog/015-publishing-retro.md +0 -264
  159. data/docs/blog/016-erb-view-templates.md +0 -431
  160. data/docs/blog/017-showcase-final-retro.md +0 -220
  161. data/docs/blog/BACKLOG.md +0 -8
  162. data/docs/blog/SERIES.md +0 -154
  163. data/lib/data_porter/components/failure_alert.rb +0 -20
  164. data/lib/data_porter/components/preview_table.rb +0 -54
  165. data/lib/data_porter/components/progress_bar.rb +0 -33
  166. data/lib/data_porter/components/results_summary.rb +0 -19
  167. data/lib/data_porter/components/status_badge.rb +0 -16
  168. 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,128 @@
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
+
78
+ .dp-btn--primary { background: var(--dp-primary); color: white; box-shadow: var(--dp-shadow-sm); }
79
+ .dp-btn--primary:hover { background: var(--dp-primary-hover); box-shadow: var(--dp-shadow-md); }
80
+
81
+ .dp-btn--secondary { background: white; color: var(--dp-gray-700); border-color: var(--dp-gray-300); box-shadow: var(--dp-shadow-sm); }
82
+ .dp-btn--secondary:hover { background: var(--dp-gray-50); border-color: var(--dp-gray-400); }
83
+
84
+ .dp-btn--danger { background: var(--dp-danger); color: white; box-shadow: var(--dp-shadow-sm); }
85
+ .dp-btn--danger:hover { background: var(--dp-danger-hover); box-shadow: var(--dp-shadow-md); }
86
+
87
+ .dp-link { color: var(--dp-primary); text-decoration: none; font-weight: 500; transition: color 0.15s ease; }
88
+ .dp-link:hover { color: var(--dp-primary-hover); text-decoration: underline; }
89
+
90
+ .dp-nav { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--dp-gray-200); }
91
+ .dp-empty { text-align: center; padding: 3rem 1rem; color: var(--dp-gray-400); font-size: 0.9375rem; }
92
+
93
+ .dp-empty-state {
94
+ text-align: center;
95
+ padding: 4rem 2rem;
96
+ background: white;
97
+ border: 2px dashed var(--dp-gray-300);
98
+ border-radius: var(--dp-radius-xl);
99
+ }
100
+
101
+ .dp-empty-state__icon { font-size: 3rem; margin-bottom: 1rem; opacity: 0.6; }
102
+ .dp-empty-state__text { font-size: 1.125rem; color: var(--dp-gray-500); margin: 0 0 1.5rem; }
103
+
104
+ .dp-import-details {
105
+ background: white;
106
+ padding: 1.5rem;
107
+ border: 1px solid var(--dp-gray-200);
108
+ border-radius: var(--dp-radius-lg);
109
+ margin-bottom: 2rem;
110
+ }
111
+
112
+ .dp-details-grid {
113
+ display: grid;
114
+ grid-template-columns: auto 1fr;
115
+ gap: 0.5rem 1.5rem;
116
+ margin: 0;
117
+ font-size: 0.9375rem;
118
+ }
119
+
120
+ .dp-details-grid dt {
121
+ font-weight: 600;
122
+ color: var(--dp-gray-500);
123
+ }
124
+
125
+ .dp-details-grid dd {
126
+ margin: 0;
127
+ color: var(--dp-gray-800);
128
+ }
@@ -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,37 @@
1
+ .dp-progress {
2
+ margin: 2rem 0;
3
+ background: var(--dp-gray-200);
4
+ border-radius: var(--dp-radius-xl);
5
+ overflow: hidden;
6
+ height: 1.75rem;
7
+ box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.08);
8
+ }
9
+
10
+ .dp-progress-bar {
11
+ 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);
15
+ display: flex;
16
+ align-items: center;
17
+ justify-content: center;
18
+ color: white;
19
+ font-size: 0.75rem;
20
+ font-weight: 700;
21
+ min-width: 2.5rem;
22
+ position: relative;
23
+ overflow: hidden;
24
+ }
25
+
26
+ .dp-progress-bar::after {
27
+ content: "";
28
+ position: absolute;
29
+ inset: 0;
30
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
31
+ animation: dp-shimmer 1.5s infinite;
32
+ }
33
+
34
+ @keyframes dp-shimmer {
35
+ 0% { transform: translateX(-100%); }
36
+ 100% { transform: translateX(100%); }
37
+ }
@@ -0,0 +1,45 @@
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
+ }
@@ -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]
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_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,6 +39,13 @@ 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
44
50
  DataPorter::ImportJob.perform_later(@import.id)
45
51
  redirect_to import_path(@import)
@@ -61,8 +67,66 @@ module DataPorter
61
67
  @import = DataPorter::DataImport.find(params[:id])
62
68
  end
63
69
 
70
+ def load_targets
71
+ @targets = DataPorter::Registry.available
72
+ end
73
+
74
+ def build_import
75
+ @import = DataPorter::DataImport.new(import_params)
76
+ @import.user = current_user if respond_to?(:current_user, true)
77
+ @import.status = :pending
78
+ end
79
+
64
80
  def import_params
65
81
  params.require(:data_import).permit(:target_key, :source_type, :file, config: {})
66
82
  end
83
+
84
+ def valid_file_presence?
85
+ return true unless %w[csv json xlsx].include?(@import.source_type)
86
+ return true if @import.file.attached?
87
+
88
+ @import.errors.add(:file, "must be attached for #{@import.source_type.upcase} imports")
89
+ false
90
+ end
91
+
92
+ def enqueue_after_create
93
+ if @import.file_based?
94
+ DataPorter::ExtractHeadersJob.perform_later(@import.id)
95
+ else
96
+ DataPorter::ParseJob.perform_later(@import.id)
97
+ end
98
+ 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
67
131
  end
68
132
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ class MappingTemplatesController < DataPorter.configuration.parent_controller.constantize
5
+ layout "data_porter/application"
6
+
7
+ before_action :set_template, only: %i[edit update destroy]
8
+
9
+ def index
10
+ @templates = MappingTemplate.order(:target_key, :name)
11
+ @grouped = @templates.group_by(&:target_key)
12
+ end
13
+
14
+ def new
15
+ @template = MappingTemplate.new
16
+ load_form_data
17
+ end
18
+
19
+ def create
20
+ @template = MappingTemplate.new(template_params)
21
+
22
+ if @template.save
23
+ redirect_to mapping_templates_path
24
+ else
25
+ load_form_data
26
+ render :new, status: :unprocessable_entity
27
+ end
28
+ end
29
+
30
+ def edit
31
+ load_form_data
32
+ end
33
+
34
+ def update
35
+ if @template.update(template_params)
36
+ redirect_to mapping_templates_path
37
+ else
38
+ load_form_data
39
+ render :edit, status: :unprocessable_entity
40
+ end
41
+ end
42
+
43
+ def destroy
44
+ @template.destroy
45
+ redirect_to mapping_templates_path
46
+ end
47
+
48
+ private
49
+
50
+ def set_template
51
+ @template = MappingTemplate.find(params[:id])
52
+ end
53
+
54
+ def template_params
55
+ permitted = params.require(:mapping_template).permit(
56
+ :target_key, :name, mapping: {}, mapping_keys: [], mapping_values: []
57
+ )
58
+ build_mapping_from_arrays(permitted)
59
+ end
60
+
61
+ def build_mapping_from_arrays(raw)
62
+ keys = raw.delete(:mapping_keys)
63
+ values = raw.delete(:mapping_values)
64
+ return raw unless keys && values
65
+
66
+ mapping = keys.zip(values).reject { |k, _| k.blank? }.to_h
67
+ raw.merge(mapping: mapping)
68
+ end
69
+
70
+ def load_form_data
71
+ @targets = Registry.available
72
+ @target_columns_map = build_target_columns_map
73
+ end
74
+
75
+ def build_target_columns_map
76
+ columns = {}
77
+ @targets.each do |t|
78
+ target = Registry.find(t[:key])
79
+ cols = target._columns || []
80
+ columns[t[:key].to_s] = cols.map { |c| [c.label, c.name.to_s] }
81
+ end
82
+ columns
83
+ end
84
+ end
85
+ end