data_migration_for_rails 0.1.1

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 (94) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +17 -0
  3. data/README.md +196 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/config/manifest.js +2 -0
  6. data/app/assets/stylesheets/application.css +15 -0
  7. data/app/channels/application_cable/channel.rb +6 -0
  8. data/app/channels/application_cable/connection.rb +6 -0
  9. data/app/controllers/concerns/data_migration/pundit_authorization.rb +12 -0
  10. data/app/controllers/data_migration/application_controller.rb +63 -0
  11. data/app/controllers/data_migration/exports_controller.rb +68 -0
  12. data/app/controllers/data_migration/imports_controller.rb +78 -0
  13. data/app/controllers/data_migration/migration_executions_controller.rb +75 -0
  14. data/app/controllers/data_migration/migration_plans_controller.rb +103 -0
  15. data/app/controllers/data_migration/migration_steps_controller.rb +164 -0
  16. data/app/controllers/data_migration/users_controller.rb +71 -0
  17. data/app/controllers/users/sessions_controller.rb +30 -0
  18. data/app/helpers/data_migration/application_helper.rb +24 -0
  19. data/app/jobs/application_job.rb +9 -0
  20. data/app/jobs/export_job.rb +27 -0
  21. data/app/jobs/import_job.rb +28 -0
  22. data/app/mailers/application_mailer.rb +6 -0
  23. data/app/models/application_record.rb +5 -0
  24. data/app/models/data_migration_user.rb +43 -0
  25. data/app/models/migration_execution.rb +93 -0
  26. data/app/models/migration_plan.rb +23 -0
  27. data/app/models/migration_record.rb +60 -0
  28. data/app/models/migration_step.rb +150 -0
  29. data/app/policies/application_policy.rb +53 -0
  30. data/app/policies/data_migration/user_policy.rb +27 -0
  31. data/app/policies/data_migration_user_policy.rb +37 -0
  32. data/app/policies/migration_execution_policy.rb +33 -0
  33. data/app/policies/migration_plan_policy.rb +41 -0
  34. data/app/policies/migration_step_policy.rb +29 -0
  35. data/app/services/data_migration/model_registry.rb +95 -0
  36. data/app/services/exports/generator_service.rb +444 -0
  37. data/app/services/imports/processor_service.rb +457 -0
  38. data/app/services/migration_plans/export_config_service.rb +41 -0
  39. data/app/services/migration_plans/import_config_service.rb +158 -0
  40. data/app/views/data_migration/devise/registrations/edit.html.erb +41 -0
  41. data/app/views/data_migration/devise/sessions/new.html.erb +35 -0
  42. data/app/views/data_migration/devise/shared/_error_messages.html.erb +13 -0
  43. data/app/views/data_migration/devise/shared/_links.html.erb +21 -0
  44. data/app/views/data_migration/exports/new.html.erb +85 -0
  45. data/app/views/data_migration/imports/new.html.erb +70 -0
  46. data/app/views/data_migration/migration_executions/index.html.erb +78 -0
  47. data/app/views/data_migration/migration_executions/show.html.erb +338 -0
  48. data/app/views/data_migration/migration_plans/_form.html.erb +28 -0
  49. data/app/views/data_migration/migration_plans/edit.html.erb +12 -0
  50. data/app/views/data_migration/migration_plans/index.html.erb +118 -0
  51. data/app/views/data_migration/migration_plans/new.html.erb +9 -0
  52. data/app/views/data_migration/migration_plans/show.html.erb +105 -0
  53. data/app/views/data_migration/migration_steps/_form.html.erb +473 -0
  54. data/app/views/data_migration/migration_steps/edit.html.erb +12 -0
  55. data/app/views/data_migration/migration_steps/new.html.erb +9 -0
  56. data/app/views/data_migration/users/_form.html.erb +49 -0
  57. data/app/views/data_migration/users/edit.html.erb +2 -0
  58. data/app/views/data_migration/users/index.html.erb +41 -0
  59. data/app/views/data_migration/users/new.html.erb +2 -0
  60. data/app/views/data_migration/users/show.html.erb +133 -0
  61. data/app/views/layouts/_navbar.html.erb +38 -0
  62. data/app/views/layouts/data_migration.html.erb +37 -0
  63. data/app/views/layouts/mailer.html.erb +13 -0
  64. data/app/views/layouts/mailer.text.erb +1 -0
  65. data/app/views/users/registrations/edit.html.erb +41 -0
  66. data/app/views/users/sessions/new.html.erb +35 -0
  67. data/app/views/users/shared/_error_messages.html.erb +13 -0
  68. data/app/views/users/shared/_links.html.erb +21 -0
  69. data/config/initializers/assets.rb +14 -0
  70. data/config/initializers/content_security_policy.rb +27 -0
  71. data/config/initializers/devise.rb +313 -0
  72. data/config/initializers/filter_parameter_logging.rb +10 -0
  73. data/config/initializers/inflections.rb +18 -0
  74. data/config/initializers/permissions_policy.rb +15 -0
  75. data/config/initializers/warden.rb +14 -0
  76. data/config/locales/devise.en.yml +65 -0
  77. data/config/locales/en.yml +31 -0
  78. data/config/routes.rb +62 -0
  79. data/db/migrate/20251102121659_create_migration_plans.rb +13 -0
  80. data/db/migrate/20251102122012_create_migration_steps.rb +24 -0
  81. data/db/migrate/20251105215702_create_migration_executions.rb +23 -0
  82. data/db/migrate/20251105215853_create_migration_records.rb +16 -0
  83. data/db/migrate/20251115154000_remove_unused_attributes.rb +17 -0
  84. data/db/migrate/20251116120000_add_filter_params_to_migration_executions.rb +7 -0
  85. data/db/migrate/20251118140000_create_data_migration_users.rb +27 -0
  86. data/db/migrate/20251118200641_add_user_foreign_keys.rb +15 -0
  87. data/db/migrate/20251124140000_add_attachment_export_mode_to_migration_steps.rb +9 -0
  88. data/db/schema.rb +102 -0
  89. data/db/seeds.rb +19 -0
  90. data/lib/data_migration/engine.rb +28 -0
  91. data/lib/data_migration/version.rb +5 -0
  92. data/lib/data_migration.rb +8 -0
  93. data/lib/tasks/data_migration_tasks.rake +40 -0
  94. metadata +279 -0
@@ -0,0 +1,473 @@
1
+ <%= form_with(model: [migration_plan, migration_step], local: true, class: "needs-validation", id: "migration-step-form") do |form| %>
2
+ <% if migration_step.errors.any? %>
3
+ <div class="alert alert-danger">
4
+ <h4><%= pluralize(migration_step.errors.count, "error") %> prohibited this step from being saved:</h4>
5
+ <ul class="mb-0">
6
+ <% migration_step.errors.full_messages.each do |message| %>
7
+ <li><%= message %></li>
8
+ <% end %>
9
+ </ul>
10
+ </div>
11
+ <% end %>
12
+
13
+ <div class="row">
14
+ <div class="col-md-8">
15
+ <div class="mb-3">
16
+ <%= form.label :source_model_name, "Model Name", class: "form-label" %>
17
+ <%= form.select :source_model_name,
18
+ options_for_select(model_registry.keys.sort, migration_step.source_model_name),
19
+ { include_blank: "Select a model..." },
20
+ { class: "form-select", required: true, id: "model_selector" } %>
21
+ <div class="form-text">The ActiveRecord model class name to migrate.</div>
22
+ </div>
23
+
24
+ <!-- Model Fields Display (dynamically shown when model is selected) -->
25
+ <div class="mb-3" id="model-fields-container" style="display: none;">
26
+ <div class="card">
27
+ <div class="card-header bg-light">
28
+ <h6 class="mb-0">📋 <span id="selected-model-name"></span> Fields</h6>
29
+ </div>
30
+ <div class="card-body">
31
+ <div class="row">
32
+ <div class="col-md-6">
33
+ <strong>Database Columns:</strong>
34
+ <ul id="model-columns-list" class="small mb-0" style="column-count: 2;"></ul>
35
+ </div>
36
+ <div class="col-md-6" id="model-attachments-container" style="display: none;">
37
+ <strong>📎 Attachments:</strong>
38
+ <ul id="model-attachments-list" class="small mb-0"></ul>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </div>
44
+
45
+ <div class="mb-3">
46
+ <%= form.label :filter_query, "Filter Query (optional)", class: "form-label" %>
47
+ <%= form.text_area :filter_query, class: "form-control font-monospace", rows: 3, placeholder: "e.g., where(active: true).where('created_at > ?', 1.year.ago)" %>
48
+ <div class="form-text">
49
+ Optional ActiveRecord query to filter which records to include. Leave blank to migrate all records.
50
+ <br><strong>Example:</strong> <code>where(status: "active")</code>
51
+ <br><strong>With placeholder:</strong> <code>where(status: "{{status_value}}")</code> or <code>where("created_at > ?", "{{cutoff_date}}")</code>
52
+ <br><small class="text-muted">Note: Placeholders must be inside string quotes.</small>
53
+ </div>
54
+ </div>
55
+
56
+ <!-- Column Overrides JSON Field -->
57
+ <div class="mb-3">
58
+ <div class="d-flex justify-content-between align-items-center mb-2">
59
+ <%= form.label :column_overrides, "Column Overrides (JSON)", class: "form-label mb-0" %>
60
+ <div class="btn-group btn-group-sm" role="group">
61
+ <button type="button" class="btn btn-outline-secondary" onclick="formatJSON('column_overrides')">
62
+ 📋 Format
63
+ </button>
64
+ <button type="button" class="btn btn-outline-primary" onclick="insertTemplate('column_overrides', 'basic')">
65
+ ➕ Template
66
+ </button>
67
+ <button type="button" class="btn btn-outline-info" onclick="clearField('column_overrides')">
68
+ 🗑️ Clear
69
+ </button>
70
+ </div>
71
+ </div>
72
+ <%= form.text_area :column_overrides,
73
+ value: migration_step.column_overrides.is_a?(Hash) ? JSON.pretty_generate(migration_step.column_overrides) : migration_step.column_overrides,
74
+ class: "form-control font-monospace json-field",
75
+ id: "column_overrides",
76
+ rows: 5,
77
+ placeholder: '{}',
78
+ data: { field_type: "column_overrides" } %>
79
+ <div class="invalid-feedback" id="column_overrides_error"></div>
80
+ <div class="form-text">
81
+ JSON object mapping associations to their attributes for export. Required for Association ID Mappings to work.
82
+ <br><strong>Example:</strong> <code>{"company": ["name", "code"], "role": ["title"]}</code>
83
+ <br><small class="text-muted">Note: For polymorphic associations like 'commentable', include the lookup attributes here so they're exported to CSV.</small>
84
+ </div>
85
+ </div>
86
+
87
+ <!-- Association Overrides JSON Field -->
88
+ <div class="mb-3">
89
+ <div class="d-flex justify-content-between align-items-center mb-2">
90
+ <%= form.label :association_overrides, "Association ID Mappings (JSON)", class: "form-label mb-0" %>
91
+ <div class="btn-group btn-group-sm" role="group">
92
+ <button type="button" class="btn btn-outline-secondary" onclick="formatJSON('association_overrides')">
93
+ 📋 Format
94
+ </button>
95
+ <button type="button" class="btn btn-outline-primary" onclick="insertTemplate('association_overrides', 'regular')">
96
+ ➕ Regular
97
+ </button>
98
+ <button type="button" class="btn btn-outline-primary" onclick="insertTemplate('association_overrides', 'polymorphic')">
99
+ ➕ Polymorphic
100
+ </button>
101
+ <button type="button" class="btn btn-outline-info" onclick="clearField('association_overrides')">
102
+ 🗑️ Clear
103
+ </button>
104
+ </div>
105
+ </div>
106
+ <%= form.text_area :association_overrides,
107
+ value: migration_step.association_overrides.is_a?(Hash) ? JSON.pretty_generate(migration_step.association_overrides) : migration_step.association_overrides,
108
+ class: "form-control font-monospace json-field",
109
+ id: "association_overrides",
110
+ rows: 8,
111
+ placeholder: '{}',
112
+ data: { field_type: "association_overrides" } %>
113
+ <div class="invalid-feedback" id="association_overrides_error"></div>
114
+ <div class="form-text">
115
+ JSON object for remapping foreign key IDs during import based on lookup attributes.
116
+ <br><strong>Regular Association:</strong> <code>{"company_id": {"model": "Company", "lookup_attributes": ["code"]}}</code>
117
+ <br><strong>Polymorphic:</strong> <code>{"commentable_id": {"polymorphic": true, "type_column": "commentable_type", "lookup_attributes": {"Post": ["title"], "Product": ["sku"]}}}</code>
118
+ </div>
119
+ </div>
120
+ </div>
121
+
122
+ <div class="col-md-4">
123
+ <div class="mb-3">
124
+ <%= form.label :sequence, "Execution Order", class: "form-label" %>
125
+ <%= form.number_field :sequence, class: "form-control", min: 1, required: true, value: migration_step.sequence || (migration_plan.migration_steps.maximum(:sequence) || 0) + 1 %>
126
+ <div class="form-text">
127
+ Steps are executed in ascending order. Dependencies should be migrated first.
128
+ </div>
129
+ </div>
130
+
131
+ <div class="mb-3">
132
+ <%= form.label :dependee_id, "Depends On Step (optional)", class: "form-label" %>
133
+ <%= form.collection_select :dependee_id,
134
+ migration_plan.migration_steps.where.not(id: migration_step.id).order(:sequence),
135
+ :id,
136
+ ->(step) { "#{step.sequence}. #{step.source_model_name}" },
137
+ { include_blank: "None - Independent Step" },
138
+ { class: "form-select" } %>
139
+ <div class="form-text">
140
+ Select a parent step if this step's records reference the parent step's records.
141
+ </div>
142
+ </div>
143
+
144
+ <!-- Dependee Attribute Mapping JSON Field -->
145
+ <div class="mb-3">
146
+ <div class="d-flex justify-content-between align-items-center mb-2">
147
+ <%= form.label :dependee_attribute_mapping, "Dependee Attribute Mapping (JSON)", class: "form-label mb-0" %>
148
+ <div class="btn-group btn-group-sm" role="group">
149
+ <button type="button" class="btn btn-outline-secondary" onclick="formatJSON('dependee_attribute_mapping')">
150
+ 📋 Format
151
+ </button>
152
+ <button type="button" class="btn btn-outline-primary" onclick="insertTemplate('dependee_attribute_mapping', 'basic')">
153
+ ➕ Template
154
+ </button>
155
+ <button type="button" class="btn btn-outline-info" onclick="clearField('dependee_attribute_mapping')">
156
+ 🗑️ Clear
157
+ </button>
158
+ </div>
159
+ </div>
160
+ <%= form.text_area :dependee_attribute_mapping,
161
+ value: migration_step.dependee_attribute_mapping.is_a?(Hash) ? JSON.pretty_generate(migration_step.dependee_attribute_mapping) : migration_step.dependee_attribute_mapping,
162
+ class: "form-control font-monospace json-field",
163
+ id: "dependee_attribute_mapping",
164
+ rows: 3,
165
+ placeholder: '{}',
166
+ data: { field_type: "dependee_attribute_mapping" } %>
167
+ <div class="invalid-feedback" id="dependee_attribute_mapping_error"></div>
168
+ <div class="form-text">
169
+ Maps this step's columns to the dependee step's columns for filtering during export.
170
+ <br><strong>Example:</strong> <code>{"company_id": "id"}</code>
171
+ <br><small class="text-muted">Only export records where company_id matches the IDs exported in the dependee step.</small>
172
+ </div>
173
+ </div>
174
+
175
+ <!-- Attachment Export Mode -->
176
+ <div class="mb-3" id="attachment-mode-container" style="display: none;">
177
+ <%= form.label :attachment_export_mode, "Attachment Export Mode", class: "form-label" %>
178
+ <%= form.select :attachment_export_mode,
179
+ options_for_select([
180
+ ['Ignore Attachments', 'ignore'],
181
+ ['Export as URLs', 'url'],
182
+ ['Export as Raw Data (compressed)', 'raw_data']
183
+ ], migration_step.attachment_export_mode),
184
+ {},
185
+ { class: "form-select" } %>
186
+ <div class="form-text">
187
+ Choose how to handle Active Storage attachments during export.
188
+ </div>
189
+ </div>
190
+
191
+ <div class="card bg-light">
192
+ <div class="card-body">
193
+ <h6 class="card-title">💡 Tips</h6>
194
+ <ul class="small mb-0">
195
+ <li>Migrate parent models before child models</li>
196
+ <li>Use filter queries to limit data scope</li>
197
+ <li>Column overrides include association data in exports</li>
198
+ <li>Use dependee mapping for referential integrity</li>
199
+ <li>Test with small datasets first</li>
200
+ <li>Click "Format" to prettify JSON</li>
201
+ <li>Use templates to get started quickly</li>
202
+ </ul>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ </div>
207
+
208
+ <div class="mb-3">
209
+ <%= form.submit migration_step.new_record? ? "Create Step" : "Update Step", class: "btn btn-primary", id: "submit-button" %>
210
+ </div>
211
+ <% end %>
212
+
213
+ <style>
214
+ .json-field.is-valid {
215
+ border-color: #198754 !important;
216
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");
217
+ background-repeat: no-repeat;
218
+ background-position: right calc(.375em + .1875rem) center;
219
+ background-size: calc(.75em + .375rem) calc(.75em + .375rem);
220
+ padding-right: calc(1.5em + .75rem);
221
+ }
222
+
223
+ .json-field.is-invalid {
224
+ border-color: #dc3545 !important;
225
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");
226
+ background-repeat: no-repeat;
227
+ background-position: right calc(.375em + .1875rem) center;
228
+ background-size: calc(.75em + .375rem) calc(.75em + .375rem);
229
+ padding-right: calc(1.5em + .75rem);
230
+ }
231
+
232
+ .json-field:focus {
233
+ box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
234
+ }
235
+ </style>
236
+
237
+ <script>
238
+ // Model Registry Data (cached from server)
239
+ const modelRegistry = <%= raw model_registry.to_json %>;
240
+
241
+ // JSON Templates
242
+ const templates = {
243
+ column_overrides: {
244
+ basic: {
245
+ "association_name": ["attribute1", "attribute2"],
246
+ "another_association": ["attribute3"]
247
+ }
248
+ },
249
+ association_overrides: {
250
+ regular: {
251
+ "company_id": {
252
+ "model": "Company",
253
+ "lookup_attributes": ["code"]
254
+ },
255
+ "manager_id": {
256
+ "model": "Employee",
257
+ "lookup_attributes": ["email"]
258
+ }
259
+ },
260
+ polymorphic: {
261
+ "commentable_id": {
262
+ "polymorphic": true,
263
+ "type_column": "commentable_type",
264
+ "lookup_attributes": {
265
+ "Post": ["title"],
266
+ "Article": ["title"],
267
+ "Product": ["name", "sku"]
268
+ }
269
+ }
270
+ }
271
+ },
272
+ dependee_attribute_mapping: {
273
+ basic: {
274
+ "company_id": "id",
275
+ "parent_id": "id"
276
+ }
277
+ }
278
+ };
279
+
280
+ // Validate JSON field
281
+ function validateJSON(fieldId) {
282
+ const field = document.getElementById(fieldId);
283
+ const errorDiv = document.getElementById(fieldId + '_error');
284
+ const value = field.value.trim();
285
+
286
+ // Empty is valid (optional field)
287
+ if (value === '' || value === '{}' || value === '[]') {
288
+ field.classList.remove('is-invalid');
289
+ field.classList.add('is-valid');
290
+ errorDiv.textContent = '';
291
+ return true;
292
+ }
293
+
294
+ try {
295
+ const parsed = JSON.parse(value);
296
+
297
+ // Additional validation: must be an object, not array
298
+ if (Array.isArray(parsed)) {
299
+ throw new Error('Must be a JSON object {}, not an array []');
300
+ }
301
+
302
+ field.classList.remove('is-invalid');
303
+ field.classList.add('is-valid');
304
+ errorDiv.textContent = '';
305
+ return true;
306
+ } catch (e) {
307
+ field.classList.remove('is-valid');
308
+ field.classList.add('is-invalid');
309
+ errorDiv.textContent = '⚠️ Invalid JSON: ' + e.message;
310
+ errorDiv.style.display = 'block';
311
+ return false;
312
+ }
313
+ }
314
+
315
+ // Format JSON with proper indentation
316
+ function formatJSON(fieldId) {
317
+ const field = document.getElementById(fieldId);
318
+ const value = field.value.trim();
319
+
320
+ if (value === '' || value === '{}' || value === '[]') {
321
+ field.value = '{}';
322
+ validateJSON(fieldId);
323
+ return;
324
+ }
325
+
326
+ try {
327
+ const parsed = JSON.parse(value);
328
+ field.value = JSON.stringify(parsed, null, 2);
329
+ validateJSON(fieldId);
330
+ } catch (e) {
331
+ alert('Cannot format: Invalid JSON\n\n' + e.message);
332
+ }
333
+ }
334
+
335
+ // Insert template
336
+ function insertTemplate(fieldId, templateType) {
337
+ const field = document.getElementById(fieldId);
338
+ const fieldType = field.dataset.fieldType;
339
+
340
+ if (templates[fieldType] && templates[fieldType][templateType]) {
341
+ const template = templates[fieldType][templateType];
342
+
343
+ // If field has content, ask for confirmation
344
+ if (field.value.trim() !== '' && field.value.trim() !== '{}') {
345
+ if (!confirm('This will replace the existing content. Continue?')) {
346
+ return;
347
+ }
348
+ }
349
+
350
+ field.value = JSON.stringify(template, null, 2);
351
+ validateJSON(fieldId);
352
+ field.focus();
353
+ }
354
+ }
355
+
356
+ // Clear field
357
+ function clearField(fieldId) {
358
+ const field = document.getElementById(fieldId);
359
+
360
+ if (field.value.trim() !== '' && field.value.trim() !== '{}') {
361
+ if (!confirm('Clear all content in this field?')) {
362
+ return;
363
+ }
364
+ }
365
+
366
+ field.value = '{}';
367
+ field.classList.remove('is-valid', 'is-invalid');
368
+ document.getElementById(fieldId + '_error').textContent = '';
369
+ }
370
+
371
+ // Validate all JSON fields on page load and input
372
+ document.addEventListener('DOMContentLoaded', function() {
373
+ const jsonFields = document.querySelectorAll('.json-field');
374
+
375
+ jsonFields.forEach(field => {
376
+ // Validate on load
377
+ validateJSON(field.id);
378
+
379
+ // Validate on blur
380
+ field.addEventListener('blur', function() {
381
+ validateJSON(this.id);
382
+ });
383
+
384
+ // Remove validation styling while typing
385
+ field.addEventListener('input', function() {
386
+ this.classList.remove('is-valid', 'is-invalid');
387
+ });
388
+ });
389
+
390
+ // Validate all fields before form submission
391
+ const form = document.getElementById('migration-step-form');
392
+ form.addEventListener('submit', function(e) {
393
+ let allValid = true;
394
+
395
+ jsonFields.forEach(field => {
396
+ if (!validateJSON(field.id)) {
397
+ allValid = false;
398
+ }
399
+ });
400
+
401
+ if (!allValid) {
402
+ e.preventDefault();
403
+ alert('Please fix the JSON errors before submitting.');
404
+ // Scroll to first error
405
+ const firstError = document.querySelector('.json-field.is-invalid');
406
+ if (firstError) {
407
+ firstError.scrollIntoView({ behavior: 'smooth', block: 'center' });
408
+ firstError.focus();
409
+ }
410
+ }
411
+ });
412
+
413
+ // Handle model selector change
414
+ const modelSelector = document.getElementById('model_selector');
415
+ if (modelSelector) {
416
+ // Show fields if model is already selected (edit mode)
417
+ if (modelSelector.value) {
418
+ displayModelFields(modelSelector.value);
419
+ }
420
+
421
+ modelSelector.addEventListener('change', function() {
422
+ const modelName = this.value;
423
+ if (modelName) {
424
+ displayModelFields(modelName);
425
+ } else {
426
+ // Hide fields if no model selected
427
+ document.getElementById('model-fields-container').style.display = 'none';
428
+ document.getElementById('attachment-mode-container').style.display = 'none';
429
+ }
430
+ });
431
+ }
432
+ });
433
+
434
+ // Display model fields and attachments
435
+ function displayModelFields(modelName) {
436
+ const metadata = modelRegistry[modelName];
437
+ if (!metadata) return;
438
+
439
+ // Update model name display
440
+ document.getElementById('selected-model-name').textContent = modelName;
441
+
442
+ // Display columns
443
+ const columnsList = document.getElementById('model-columns-list');
444
+ columnsList.innerHTML = '';
445
+ metadata.columns.forEach(column => {
446
+ const li = document.createElement('li');
447
+ li.innerHTML = `<code>${column.name}</code> <small class="text-muted">(${column.type})</small>`;
448
+ columnsList.appendChild(li);
449
+ });
450
+
451
+ // Display attachments if any
452
+ const attachmentsContainer = document.getElementById('model-attachments-container');
453
+ const attachmentsList = document.getElementById('model-attachments-list');
454
+ if (metadata.attachments && metadata.attachments.length > 0) {
455
+ attachmentsList.innerHTML = '';
456
+ metadata.attachments.forEach(attachment => {
457
+ const li = document.createElement('li');
458
+ li.innerHTML = `<code>${attachment.name}</code> <small class="text-muted">(${attachment.type})</small>`;
459
+ attachmentsList.appendChild(li);
460
+ });
461
+ attachmentsContainer.style.display = 'block';
462
+
463
+ // Show attachment export mode selector
464
+ document.getElementById('attachment-mode-container').style.display = 'block';
465
+ } else {
466
+ attachmentsContainer.style.display = 'none';
467
+ document.getElementById('attachment-mode-container').style.display = 'none';
468
+ }
469
+
470
+ // Show the fields container
471
+ document.getElementById('model-fields-container').style.display = 'block';
472
+ }
473
+ </script>
@@ -0,0 +1,12 @@
1
+ <div class="row justify-content-center">
2
+ <div class="col-md-10">
3
+ <h1 class="mb-4">Edit Migration Step</h1>
4
+
5
+ <%= render 'form', migration_step: @migration_step, migration_plan: @migration_plan %>
6
+
7
+ <div class="mt-3">
8
+ <%= link_to "View Plan", @migration_plan, class: "btn btn-primary" %>
9
+ <%= link_to "Cancel", @migration_plan, class: "btn btn-secondary" %>
10
+ </div>
11
+ </div>
12
+ </div>
@@ -0,0 +1,9 @@
1
+ <div class="row justify-content-center">
2
+ <div class="col-md-10">
3
+ <h1 class="mb-4">New Migration Step for <%= @migration_plan.name %></h1>
4
+
5
+ <%= render 'form', migration_step: @migration_step, migration_plan: @migration_plan %>
6
+
7
+ <%= link_to "Cancel", @migration_plan, class: "btn btn-secondary mt-3" %>
8
+ </div>
9
+ </div>
@@ -0,0 +1,49 @@
1
+ <%= form_with(model: user, url: user.new_record? ? users_path : user_path(user), local: true) do |form| %>
2
+ <% if user.errors.any? %>
3
+ <div class="alert alert-danger">
4
+ <h4><%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:</h4>
5
+ <ul class="mb-0">
6
+ <% user.errors.full_messages.each do |message| %>
7
+ <li><%= message %></li>
8
+ <% end %>
9
+ </ul>
10
+ </div>
11
+ <% end %>
12
+
13
+ <div class="mb-3">
14
+ <%= form.label :name, class: "form-label" %>
15
+ <%= form.text_field :name, class: "form-control", required: true %>
16
+ </div>
17
+
18
+ <div class="mb-3">
19
+ <%= form.label :email, class: "form-label" %>
20
+ <%= form.email_field :email, class: "form-control", required: true %>
21
+ </div>
22
+
23
+ <div class="mb-3">
24
+ <%= form.label :password, class: "form-label" %>
25
+ <%= form.password_field :password, class: "form-control", autocomplete: "new-password" %>
26
+ <% unless user.new_record? %>
27
+ <div class="form-text">Leave blank to keep current password</div>
28
+ <% end %>
29
+ </div>
30
+
31
+ <div class="mb-3">
32
+ <%= form.label :password_confirmation, class: "form-label" %>
33
+ <%= form.password_field :password_confirmation, class: "form-control", autocomplete: "new-password" %>
34
+ </div>
35
+
36
+ <div class="mb-3">
37
+ <%= form.label :role, class: "form-label" %>
38
+ <%= form.select :role, DataMigrationUser.roles.keys.map { |role| [role.titleize, role] },
39
+ {}, { class: "form-select", required: true } %>
40
+ <div class="form-text">
41
+ <strong>Admin:</strong> Full access<br>
42
+ <strong>Operator:</strong> Can execute exports/imports<br>
43
+ <strong>Viewer:</strong> Read-only access
44
+ </div>
45
+ </div>
46
+
47
+ <%= form.submit user.new_record? ? "Create User" : "Update User", class: "btn btn-primary" %>
48
+ <%= link_to "Cancel", users_path, class: "btn btn-secondary" %>
49
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <h1>Edit User</h1>
2
+ <%= render 'form', user: @user %>
@@ -0,0 +1,41 @@
1
+ <div class="d-flex justify-content-between align-items-center mb-4">
2
+ <h1>Users</h1>
3
+ <%= link_to "New User", new_user_path, class: "btn btn-primary" %>
4
+ </div>
5
+
6
+ <div class="table-responsive">
7
+ <table class="table table-hover">
8
+ <thead class="table-light">
9
+ <tr>
10
+ <th>Name</th>
11
+ <th>Email</th>
12
+ <th>Role</th>
13
+ <th>Created</th>
14
+ <th>Actions</th>
15
+ </tr>
16
+ </thead>
17
+ <tbody>
18
+ <% @users.each do |user| %>
19
+ <tr>
20
+ <td><%= user.name %></td>
21
+ <td><%= user.email %></td>
22
+ <td>
23
+ <span class="badge bg-<%= user.admin? ? 'danger' : (user.operator? ? 'primary' : 'secondary') %>">
24
+ <%= user.role.titleize %>
25
+ </span>
26
+ </td>
27
+ <td><%= time_ago_in_words(user.created_at) %> ago</td>
28
+ <td>
29
+ <%= link_to "Edit", edit_user_path(user), class: "btn btn-sm btn-outline-warning" %>
30
+ <% if user != current_user %>
31
+ <%= button_to "Delete", user_path(user),
32
+ method: :delete,
33
+ class: "btn btn-sm btn-outline-danger",
34
+ data: { confirm: "Are you sure?" } %>
35
+ <% end %>
36
+ </td>
37
+ </tr>
38
+ <% end %>
39
+ </tbody>
40
+ </table>
41
+ </div>
@@ -0,0 +1,2 @@
1
+ <h1>New User</h1>
2
+ <%= render 'form', user: @user %>