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.
- checksums.yaml +7 -0
- data/LICENSE +17 -0
- data/README.md +196 -0
- data/Rakefile +8 -0
- data/app/assets/config/manifest.js +2 -0
- data/app/assets/stylesheets/application.css +15 -0
- data/app/channels/application_cable/channel.rb +6 -0
- data/app/channels/application_cable/connection.rb +6 -0
- data/app/controllers/concerns/data_migration/pundit_authorization.rb +12 -0
- data/app/controllers/data_migration/application_controller.rb +63 -0
- data/app/controllers/data_migration/exports_controller.rb +68 -0
- data/app/controllers/data_migration/imports_controller.rb +78 -0
- data/app/controllers/data_migration/migration_executions_controller.rb +75 -0
- data/app/controllers/data_migration/migration_plans_controller.rb +103 -0
- data/app/controllers/data_migration/migration_steps_controller.rb +164 -0
- data/app/controllers/data_migration/users_controller.rb +71 -0
- data/app/controllers/users/sessions_controller.rb +30 -0
- data/app/helpers/data_migration/application_helper.rb +24 -0
- data/app/jobs/application_job.rb +9 -0
- data/app/jobs/export_job.rb +27 -0
- data/app/jobs/import_job.rb +28 -0
- data/app/mailers/application_mailer.rb +6 -0
- data/app/models/application_record.rb +5 -0
- data/app/models/data_migration_user.rb +43 -0
- data/app/models/migration_execution.rb +93 -0
- data/app/models/migration_plan.rb +23 -0
- data/app/models/migration_record.rb +60 -0
- data/app/models/migration_step.rb +150 -0
- data/app/policies/application_policy.rb +53 -0
- data/app/policies/data_migration/user_policy.rb +27 -0
- data/app/policies/data_migration_user_policy.rb +37 -0
- data/app/policies/migration_execution_policy.rb +33 -0
- data/app/policies/migration_plan_policy.rb +41 -0
- data/app/policies/migration_step_policy.rb +29 -0
- data/app/services/data_migration/model_registry.rb +95 -0
- data/app/services/exports/generator_service.rb +444 -0
- data/app/services/imports/processor_service.rb +457 -0
- data/app/services/migration_plans/export_config_service.rb +41 -0
- data/app/services/migration_plans/import_config_service.rb +158 -0
- data/app/views/data_migration/devise/registrations/edit.html.erb +41 -0
- data/app/views/data_migration/devise/sessions/new.html.erb +35 -0
- data/app/views/data_migration/devise/shared/_error_messages.html.erb +13 -0
- data/app/views/data_migration/devise/shared/_links.html.erb +21 -0
- data/app/views/data_migration/exports/new.html.erb +85 -0
- data/app/views/data_migration/imports/new.html.erb +70 -0
- data/app/views/data_migration/migration_executions/index.html.erb +78 -0
- data/app/views/data_migration/migration_executions/show.html.erb +338 -0
- data/app/views/data_migration/migration_plans/_form.html.erb +28 -0
- data/app/views/data_migration/migration_plans/edit.html.erb +12 -0
- data/app/views/data_migration/migration_plans/index.html.erb +118 -0
- data/app/views/data_migration/migration_plans/new.html.erb +9 -0
- data/app/views/data_migration/migration_plans/show.html.erb +105 -0
- data/app/views/data_migration/migration_steps/_form.html.erb +473 -0
- data/app/views/data_migration/migration_steps/edit.html.erb +12 -0
- data/app/views/data_migration/migration_steps/new.html.erb +9 -0
- data/app/views/data_migration/users/_form.html.erb +49 -0
- data/app/views/data_migration/users/edit.html.erb +2 -0
- data/app/views/data_migration/users/index.html.erb +41 -0
- data/app/views/data_migration/users/new.html.erb +2 -0
- data/app/views/data_migration/users/show.html.erb +133 -0
- data/app/views/layouts/_navbar.html.erb +38 -0
- data/app/views/layouts/data_migration.html.erb +37 -0
- data/app/views/layouts/mailer.html.erb +13 -0
- data/app/views/layouts/mailer.text.erb +1 -0
- data/app/views/users/registrations/edit.html.erb +41 -0
- data/app/views/users/sessions/new.html.erb +35 -0
- data/app/views/users/shared/_error_messages.html.erb +13 -0
- data/app/views/users/shared/_links.html.erb +21 -0
- data/config/initializers/assets.rb +14 -0
- data/config/initializers/content_security_policy.rb +27 -0
- data/config/initializers/devise.rb +313 -0
- data/config/initializers/filter_parameter_logging.rb +10 -0
- data/config/initializers/inflections.rb +18 -0
- data/config/initializers/permissions_policy.rb +15 -0
- data/config/initializers/warden.rb +14 -0
- data/config/locales/devise.en.yml +65 -0
- data/config/locales/en.yml +31 -0
- data/config/routes.rb +62 -0
- data/db/migrate/20251102121659_create_migration_plans.rb +13 -0
- data/db/migrate/20251102122012_create_migration_steps.rb +24 -0
- data/db/migrate/20251105215702_create_migration_executions.rb +23 -0
- data/db/migrate/20251105215853_create_migration_records.rb +16 -0
- data/db/migrate/20251115154000_remove_unused_attributes.rb +17 -0
- data/db/migrate/20251116120000_add_filter_params_to_migration_executions.rb +7 -0
- data/db/migrate/20251118140000_create_data_migration_users.rb +27 -0
- data/db/migrate/20251118200641_add_user_foreign_keys.rb +15 -0
- data/db/migrate/20251124140000_add_attachment_export_mode_to_migration_steps.rb +9 -0
- data/db/schema.rb +102 -0
- data/db/seeds.rb +19 -0
- data/lib/data_migration/engine.rb +28 -0
- data/lib/data_migration/version.rb +5 -0
- data/lib/data_migration.rb +8 -0
- data/lib/tasks/data_migration_tasks.rake +40 -0
- 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,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>
|