data_porter 2.0.0 → 2.3.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +7 -1
  4. data/ROADMAP.md +64 -76
  5. data/app/assets/javascripts/data_porter/progress_controller.js +3 -3
  6. data/app/controllers/data_porter/concerns/import_validation.rb +5 -5
  7. data/app/jobs/data_porter/webhook_job.rb +45 -0
  8. data/app/views/data_porter/imports/index.html.erb +23 -23
  9. data/app/views/data_porter/imports/new.html.erb +11 -11
  10. data/app/views/data_porter/imports/show.html.erb +19 -19
  11. data/app/views/data_porter/mapping_templates/_form.html.erb +10 -10
  12. data/app/views/data_porter/mapping_templates/edit.html.erb +2 -2
  13. data/app/views/data_porter/mapping_templates/index.html.erb +10 -10
  14. data/app/views/data_porter/mapping_templates/new.html.erb +2 -2
  15. data/config/locales/en.yml +123 -0
  16. data/config/locales/fr.yml +123 -0
  17. data/config/routes.rb +2 -2
  18. data/lib/data_porter/column_transformer.rb +56 -0
  19. data/lib/data_porter/components/mapping/column_row.rb +1 -1
  20. data/lib/data_porter/components/mapping/form.rb +4 -4
  21. data/lib/data_porter/components/mapping/template_select.rb +1 -1
  22. data/lib/data_porter/components/preview/results_summary.rb +13 -5
  23. data/lib/data_porter/components/preview/summary_cards.rb +5 -4
  24. data/lib/data_porter/components/preview/table.rb +3 -3
  25. data/lib/data_porter/components/progress/bar.rb +9 -2
  26. data/lib/data_porter/components/shared/pagination.rb +9 -5
  27. data/lib/data_porter/components/shared/status_badge.rb +3 -1
  28. data/lib/data_porter/configuration.rb +3 -1
  29. data/lib/data_porter/dsl/column.rb +3 -2
  30. data/lib/data_porter/dsl/webhook.rb +31 -0
  31. data/lib/data_porter/engine.rb +4 -0
  32. data/lib/data_porter/orchestrator/importer.rb +1 -0
  33. data/lib/data_porter/orchestrator/record_builder.rb +2 -1
  34. data/lib/data_porter/orchestrator.rb +3 -0
  35. data/lib/data_porter/record_validator.rb +2 -2
  36. data/lib/data_porter/target.rb +11 -1
  37. data/lib/data_porter/version.rb +1 -1
  38. data/lib/data_porter/webhook_notifier.rb +100 -0
  39. data/lib/data_porter.rb +2 -0
  40. data/lib/generators/data_porter/install/templates/initializer.rb +5 -0
  41. data/lib/generators/data_porter/locale/locale_generator.rb +42 -0
  42. data/mkdocs.yml +98 -0
  43. metadata +10 -3
  44. data/bookmarklet.md +0 -217
@@ -1,28 +1,28 @@
1
1
  <div class="data-porter">
2
2
  <div class="dp-header">
3
3
  <div class="dp-header__actions">
4
- <%= link_to "Back to imports", imports_path, class: "dp-btn dp-btn--secondary" %>
4
+ <%= link_to t("data_porter.imports.back_to_imports"), imports_path, class: "dp-btn dp-btn--secondary" %>
5
5
  </div>
6
6
  <h1 class="dp-title">
7
- <%= @target._label %> Import #<%= @import.id %>
7
+ <%= t("data_porter.imports.show_title", target: @target._label, id: @import.id) %>
8
8
  </h1>
9
9
  <%= raw DataPorter::Components::Shared::StatusBadge.new(status: @import.status).call %>
10
10
  </div>
11
11
 
12
12
  <div class="dp-import-details">
13
13
  <dl class="dp-details-grid">
14
- <dt>Target</dt>
14
+ <dt><%= t("data_porter.imports.details.target") %></dt>
15
15
  <dd><%= @target._label %></dd>
16
- <dt>Source</dt>
16
+ <dt><%= t("data_porter.imports.details.source") %></dt>
17
17
  <dd><%= @import.source_type.upcase %></dd>
18
18
  <% if @import.file.attached? %>
19
- <dt>File</dt>
19
+ <dt><%= t("data_porter.imports.details.file") %></dt>
20
20
  <dd><%= @import.file.filename %></dd>
21
21
  <% end %>
22
- <dt>Created</dt>
22
+ <dt><%= t("data_porter.imports.details.created") %></dt>
23
23
  <dd><%= @import.created_at&.strftime("%Y-%m-%d %H:%M") %></dd>
24
24
  <% if @import.report.records_count.positive? %>
25
- <dt>Records</dt>
25
+ <dt><%= t("data_porter.imports.details.records") %></dt>
26
26
  <dd><%= @import.report.records_count %></dd>
27
27
  <% end %>
28
28
  </dl>
@@ -58,18 +58,18 @@
58
58
 
59
59
  <div class="dp-actions">
60
60
  <%= button_to confirm_import_path(@import), method: :post, class: "dp-btn dp-btn--primary", data: { dp_submit: true } do %>
61
- Confirm Import
61
+ <%= t("data_porter.imports.confirm_import") %>
62
62
  <% end %>
63
63
  <% if @target._dry_run_enabled %>
64
64
  <%= button_to dry_run_import_path(@import), method: :post, class: "dp-btn dp-btn--secondary", data: { dp_submit: true } do %>
65
- Dry Run
65
+ <%= t("data_porter.imports.dry_run") %>
66
66
  <% end %>
67
67
  <% end %>
68
68
  <% if @import.file_based? %>
69
- <%= button_to "Back to Mapping", back_to_mapping_import_path(@import),
69
+ <%= button_to t("data_porter.imports.back_to_mapping"), back_to_mapping_import_path(@import),
70
70
  method: :post, class: "dp-btn dp-btn--secondary" %>
71
71
  <% end %>
72
- <%= button_to "Cancel", cancel_import_path(@import),
72
+ <%= button_to t("data_porter.imports.cancel"), cancel_import_path(@import),
73
73
  method: :post, class: "dp-btn dp-btn--danger" %>
74
74
  </div>
75
75
  <% end %>
@@ -89,25 +89,25 @@
89
89
  ).call %>
90
90
  <% end %>
91
91
  <div class="dp-actions">
92
- <%= link_to "Back to imports", imports_path, class: "dp-btn dp-btn--primary" %>
92
+ <%= link_to t("data_porter.imports.back_to_imports"), imports_path, class: "dp-btn dp-btn--primary" %>
93
93
  <% rejected = @import.report.errored_count.to_i + @import.report.missing_count.to_i + @import.report.partial_count.to_i %>
94
94
  <% if rejected.positive? %>
95
- <%= link_to "Download rejects CSV", export_rejects_import_path(@import), class: "dp-btn dp-btn--secondary" %>
95
+ <%= link_to t("data_porter.imports.download_rejects"), export_rejects_import_path(@import), class: "dp-btn dp-btn--secondary" %>
96
96
  <% end %>
97
- <%= button_to "Delete", import_path(@import),
97
+ <%= button_to t("data_porter.imports.delete"), import_path(@import),
98
98
  method: :delete, class: "dp-btn dp-btn--danger",
99
- data: { turbo_confirm: "Delete this import?" } %>
99
+ data: { turbo_confirm: t("data_porter.imports.delete_confirm") } %>
100
100
  </div>
101
101
  <% end %>
102
102
 
103
103
  <% if @import.failed? %>
104
104
  <%= raw DataPorter::Components::Shared::FailureAlert.new(report: @import.report).call %>
105
105
  <div class="dp-actions">
106
- <%= button_to "Retry", parse_import_path(@import),
106
+ <%= button_to t("data_porter.imports.retry"), parse_import_path(@import),
107
107
  method: :post, class: "dp-btn dp-btn--primary" %>
108
- <%= button_to "Delete", import_path(@import),
108
+ <%= button_to t("data_porter.imports.delete"), import_path(@import),
109
109
  method: :delete, class: "dp-btn dp-btn--danger",
110
- data: { turbo_confirm: "Delete this import?" } %>
110
+ data: { turbo_confirm: t("data_porter.imports.delete_confirm") } %>
111
111
  </div>
112
112
  <% end %>
113
113
  </div>
@@ -117,7 +117,7 @@
117
117
  document.querySelectorAll("[data-dp-submit]").forEach(function(btn) {
118
118
  btn.closest("form").addEventListener("submit", function() {
119
119
  btn.disabled = true;
120
- btn.innerHTML = '<span class="dp-spinner"></span>Processing...';
120
+ btn.innerHTML = '<span class="dp-spinner"></span><%= j t("data_porter.imports.processing") %>';
121
121
  });
122
122
  });
123
123
  })();
@@ -1,26 +1,26 @@
1
1
  <%= form_with model: template, url: template.persisted? ? mapping_template_path(template) : mapping_templates_path, class: "dp-form", method: template.persisted? ? :patch : :post, data: { controller: "data-porter--template-form", "data-porter--template-form-columns-value": target_columns_map.to_json } do |f| %>
2
2
  <div class="dp-field">
3
- <%= f.label :target_key, "Target", class: "dp-label" %>
3
+ <%= f.label :target_key, t("data_porter.mapping_templates.form.target_label"), class: "dp-label" %>
4
4
  <%= f.select :target_key,
5
5
  targets.map { |t| [t[:label], t[:key]] },
6
- { prompt: "Select a target..." },
6
+ { prompt: t("data_porter.mapping_templates.form.target_prompt") },
7
7
  class: "dp-select",
8
8
  data: { action: "change->data-porter--template-form#targetChanged" } %>
9
9
  </div>
10
10
 
11
11
  <div class="dp-field">
12
- <%= f.label :name, "Template Name", class: "dp-label" %>
13
- <%= f.text_field :name, class: "dp-select", placeholder: "e.g. French Headers" %>
12
+ <%= f.label :name, t("data_porter.mapping_templates.form.name_label"), class: "dp-label" %>
13
+ <%= f.text_field :name, class: "dp-select", placeholder: t("data_porter.mapping_templates.form.name_placeholder") %>
14
14
  </div>
15
15
 
16
16
  <div class="dp-field">
17
- <label class="dp-label">Column Mappings</label>
17
+ <label class="dp-label"><%= t("data_porter.mapping_templates.form.column_mappings") %></label>
18
18
  <div id="dp-mapping-pairs" data-data-porter--template-form-target="pairsContainer">
19
19
  <% (template.mapping.presence || { "" => "" }).each do |header, field| %>
20
20
  <div class="dp-mapping-pair" style="display: flex; gap: 0.5rem; margin-bottom: 0.5rem;">
21
- <input type="text" name="mapping_template[mapping_keys][]" value="<%= header %>" placeholder="File header" class="dp-select" style="flex: 1;" />
21
+ <input type="text" name="mapping_template[mapping_keys][]" value="<%= header %>" placeholder="<%= t("data_porter.mapping_templates.form.file_header_placeholder") %>" class="dp-select" style="flex: 1;" />
22
22
  <select name="mapping_template[mapping_values][]" class="dp-select" style="flex: 1;" data-data-porter--template-form-target="fieldSelect">
23
- <option value="">Select a field...</option>
23
+ <option value=""><%= t("data_porter.mapping_templates.form.field_prompt") %></option>
24
24
  <% if template.target_key.present? && target_columns_map[template.target_key.to_s].present? %>
25
25
  <% target_columns_map[template.target_key.to_s].each do |label, name| %>
26
26
  <option value="<%= name %>" <%= "selected" if name == field.to_s %>><%= label %></option>
@@ -30,11 +30,11 @@
30
30
  </div>
31
31
  <% end %>
32
32
  </div>
33
- <button type="button" class="dp-btn dp-btn--secondary" style="margin-top: 0.5rem;" data-action="data-porter--template-form#addPair">+ Add Mapping</button>
33
+ <button type="button" class="dp-btn dp-btn--secondary" style="margin-top: 0.5rem;" data-action="data-porter--template-form#addPair"><%= t("data_porter.mapping_templates.form.add_mapping") %></button>
34
34
  </div>
35
35
 
36
36
  <div class="dp-actions">
37
- <%= f.submit template.persisted? ? "Update Template" : "Create Template", class: "dp-btn dp-btn--primary" %>
38
- <%= link_to "Cancel", mapping_templates_path, class: "dp-btn dp-btn--secondary" %>
37
+ <%= f.submit template.persisted? ? t("data_porter.mapping_templates.form.update") : t("data_porter.mapping_templates.form.create"), class: "dp-btn dp-btn--primary" %>
38
+ <%= link_to t("data_porter.mapping_templates.form.cancel"), mapping_templates_path, class: "dp-btn dp-btn--secondary" %>
39
39
  </div>
40
40
  <% end %>
@@ -1,11 +1,11 @@
1
1
  <div class="data-porter">
2
2
  <div class="dp-header">
3
- <h1 class="dp-title">Edit Mapping Template</h1>
3
+ <h1 class="dp-title"><%= t("data_porter.mapping_templates.edit_title") %></h1>
4
4
  </div>
5
5
 
6
6
  <%= render "form", template: @template, targets: @targets, target_columns_map: @target_columns_map %>
7
7
 
8
8
  <div class="dp-nav">
9
- <%= link_to "Back to templates", mapping_templates_path, class: "dp-link" %>
9
+ <%= link_to t("data_porter.mapping_templates.back_to_templates"), mapping_templates_path, class: "dp-link" %>
10
10
  </div>
11
11
  </div>
@@ -1,10 +1,10 @@
1
1
  <div class="data-porter">
2
2
  <div class="dp-header">
3
3
  <div class="dp-header__actions">
4
- <%= link_to "Back to imports", imports_path, class: "dp-btn dp-btn--secondary" %>
4
+ <%= link_to t("data_porter.mapping_templates.back_to_imports"), imports_path, class: "dp-btn dp-btn--secondary" %>
5
5
  </div>
6
- <h1 class="dp-title">Mapping Templates</h1>
7
- <%= link_to "New Template", new_mapping_template_path, class: "dp-btn dp-btn--primary" %>
6
+ <h1 class="dp-title"><%= t("data_porter.mapping_templates.title") %></h1>
7
+ <%= link_to t("data_porter.mapping_templates.new_template"), new_mapping_template_path, class: "dp-btn dp-btn--primary" %>
8
8
  </div>
9
9
 
10
10
  <% if @grouped.any? %>
@@ -13,8 +13,8 @@
13
13
  <table class="dp-table">
14
14
  <thead>
15
15
  <tr>
16
- <th>Name</th>
17
- <th>Mappings</th>
16
+ <th><%= t("data_porter.mapping_templates.name") %></th>
17
+ <th><%= t("data_porter.mapping_templates.mappings") %></th>
18
18
  <th></th>
19
19
  </tr>
20
20
  </thead>
@@ -22,10 +22,10 @@
22
22
  <% templates.each do |template| %>
23
23
  <tr>
24
24
  <td><%= template.name %></td>
25
- <td><%= template.mapping.size %> columns</td>
25
+ <td><%= t("data_porter.mapping_templates.columns_count", count: template.mapping.size) %></td>
26
26
  <td style="display: flex; gap: 0.5rem; align-items: center;">
27
- <%= link_to "Edit", edit_mapping_template_path(template), class: "dp-btn dp-btn--secondary" %>
28
- <%= button_to "Delete", mapping_template_path(template), method: :delete, class: "dp-btn dp-btn--danger" %>
27
+ <%= link_to t("data_porter.mapping_templates.edit"), edit_mapping_template_path(template), class: "dp-btn dp-btn--secondary" %>
28
+ <%= button_to t("data_porter.mapping_templates.delete"), mapping_template_path(template), method: :delete, class: "dp-btn dp-btn--danger" %>
29
29
  </td>
30
30
  </tr>
31
31
  <% end %>
@@ -35,8 +35,8 @@
35
35
  <% else %>
36
36
  <div class="dp-empty-state">
37
37
  <div class="dp-empty-state__icon">&#128203;</div>
38
- <p class="dp-empty-state__text">No mapping templates yet</p>
39
- <%= link_to "Create your first template", new_mapping_template_path, class: "dp-btn dp-btn--primary" %>
38
+ <p class="dp-empty-state__text"><%= t("data_porter.mapping_templates.no_templates") %></p>
39
+ <%= link_to t("data_porter.mapping_templates.create_first"), new_mapping_template_path, class: "dp-btn dp-btn--primary" %>
40
40
  </div>
41
41
  <% end %>
42
42
  </div>
@@ -1,11 +1,11 @@
1
1
  <div class="data-porter">
2
2
  <div class="dp-header">
3
- <h1 class="dp-title">New Mapping Template</h1>
3
+ <h1 class="dp-title"><%= t("data_porter.mapping_templates.new_title") %></h1>
4
4
  </div>
5
5
 
6
6
  <%= render "form", template: @template, targets: @targets, target_columns_map: @target_columns_map %>
7
7
 
8
8
  <div class="dp-nav">
9
- <%= link_to "Back to templates", mapping_templates_path, class: "dp-link" %>
9
+ <%= link_to t("data_porter.mapping_templates.back_to_templates"), mapping_templates_path, class: "dp-link" %>
10
10
  </div>
11
11
  </div>
@@ -0,0 +1,123 @@
1
+ en:
2
+ data_porter:
3
+ imports:
4
+ title: "Imports"
5
+ new_import: "New Import"
6
+ new_title: "New Import"
7
+ show_title: "%{target} Import #%{id}"
8
+ back_to_imports: "Back to imports"
9
+ view: "View"
10
+ delete: "Delete"
11
+ delete_confirm: "Delete this import?"
12
+ retry: "Retry"
13
+ start_import: "Start Import"
14
+ confirm_import: "Confirm Import"
15
+ dry_run: "Dry Run"
16
+ cancel: "Cancel"
17
+ back_to_mapping: "Back to Mapping"
18
+ download_rejects: "Download rejects CSV"
19
+ processing: "Processing..."
20
+ no_imports: "No imports yet"
21
+ create_first: "Create your first import"
22
+ details:
23
+ target: "Target"
24
+ source: "Source"
25
+ file: "File"
26
+ created: "Created"
27
+ records: "Records"
28
+ table:
29
+ id: "ID"
30
+ target: "Target"
31
+ source: "Source"
32
+ status: "Status"
33
+ created: "Created"
34
+ form:
35
+ target_label: "Target"
36
+ target_prompt: "Select a target..."
37
+ source_label: "Source Type"
38
+ source_prompt: "Select source type..."
39
+ file_label: "File"
40
+ dropzone_text: "Drop your file here or <strong>browse</strong>"
41
+ dropzone_hint: "CSV, JSON, or XLSX files accepted"
42
+ select_prompt: "Select..."
43
+ mapping:
44
+ load_template: "Load Template"
45
+ save_as_template: "Save as template"
46
+ template_name_placeholder: "Template name"
47
+ continue: "Continue"
48
+ skip_column: "Skip this column"
49
+ select_template: "Select a template..."
50
+ mapping_templates:
51
+ title: "Mapping Templates"
52
+ new_template: "New Template"
53
+ new_title: "New Mapping Template"
54
+ edit_title: "Edit Mapping Template"
55
+ back_to_templates: "Back to templates"
56
+ back_to_imports: "Back to imports"
57
+ name: "Name"
58
+ mappings: "Mappings"
59
+ columns_count:
60
+ one: "%{count} column"
61
+ other: "%{count} columns"
62
+ edit: "Edit"
63
+ delete: "Delete"
64
+ no_templates: "No mapping templates yet"
65
+ create_first: "Create your first template"
66
+ form:
67
+ target_label: "Target"
68
+ target_prompt: "Select a target..."
69
+ name_label: "Template Name"
70
+ name_placeholder: "e.g. French Headers"
71
+ column_mappings: "Column Mappings"
72
+ file_header_placeholder: "File header"
73
+ field_prompt: "Select a field..."
74
+ add_mapping: "+ Add Mapping"
75
+ create: "Create Template"
76
+ update: "Update Template"
77
+ cancel: "Cancel"
78
+ components:
79
+ summary_cards:
80
+ ready: "Ready"
81
+ incomplete: "Incomplete"
82
+ missing: "Missing"
83
+ duplicates: "Duplicates"
84
+ results_summary:
85
+ completed: "Import completed"
86
+ completed_with_errors: "Import completed with errors"
87
+ imported: "Imported"
88
+ errors: "Errors"
89
+ skipped: "Skipped"
90
+ duration: "Duration: %{duration}"
91
+ table:
92
+ line_number: "#"
93
+ status: "Status"
94
+ errors: "Errors"
95
+ progress:
96
+ waiting: "Waiting..."
97
+ extracting_headers: "Extracting headers..."
98
+ parsing: "Parsing records..."
99
+ importing: "Importing..."
100
+ dry_running: "Dry run..."
101
+ processing: "Processing..."
102
+ pagination:
103
+ previous: "Previous"
104
+ next: "Next"
105
+ page_info: "Page %{page} of %{total}"
106
+ status_badge:
107
+ pending: "Pending"
108
+ extracting_headers: "Extracting headers"
109
+ mapping: "Mapping"
110
+ parsing: "Parsing"
111
+ previewing: "Previewing"
112
+ importing: "Importing"
113
+ completed: "Completed"
114
+ failed: "Failed"
115
+ dry_running: "Dry running"
116
+ errors:
117
+ required: "%{label} is required"
118
+ invalid_type: "%{label}: invalid %{type}"
119
+ max_records: "File contains %{count} records, exceeds maximum of %{max}"
120
+ source_unavailable: "%{source} is not available for this target"
121
+ file_required: "must be attached for %{source} imports"
122
+ file_too_large: "is too large (max %{max} MB)"
123
+ invalid_content_type: "has an invalid content type (%{type})"
@@ -0,0 +1,123 @@
1
+ fr:
2
+ data_porter:
3
+ imports:
4
+ title: "Imports"
5
+ new_import: "Nouvel import"
6
+ new_title: "Nouvel import"
7
+ show_title: "Import %{target} #%{id}"
8
+ back_to_imports: "Retour aux imports"
9
+ view: "Voir"
10
+ delete: "Supprimer"
11
+ delete_confirm: "Supprimer cet import ?"
12
+ retry: "Réessayer"
13
+ start_import: "Lancer l'import"
14
+ confirm_import: "Confirmer l'import"
15
+ dry_run: "Essai à blanc"
16
+ cancel: "Annuler"
17
+ back_to_mapping: "Retour au mapping"
18
+ download_rejects: "Télécharger les rejets CSV"
19
+ processing: "Traitement..."
20
+ no_imports: "Aucun import"
21
+ create_first: "Créer votre premier import"
22
+ details:
23
+ target: "Cible"
24
+ source: "Source"
25
+ file: "Fichier"
26
+ created: "Créé le"
27
+ records: "Enregistrements"
28
+ table:
29
+ id: "ID"
30
+ target: "Cible"
31
+ source: "Source"
32
+ status: "Statut"
33
+ created: "Créé le"
34
+ form:
35
+ target_label: "Cible"
36
+ target_prompt: "Sélectionner une cible..."
37
+ source_label: "Type de source"
38
+ source_prompt: "Sélectionner un type..."
39
+ file_label: "Fichier"
40
+ dropzone_text: "Déposez votre fichier ici ou <strong>parcourir</strong>"
41
+ dropzone_hint: "Fichiers CSV, JSON ou XLSX acceptés"
42
+ select_prompt: "Sélectionner..."
43
+ mapping:
44
+ load_template: "Charger un modèle"
45
+ save_as_template: "Sauvegarder comme modèle"
46
+ template_name_placeholder: "Nom du modèle"
47
+ continue: "Continuer"
48
+ skip_column: "Ignorer cette colonne"
49
+ select_template: "Sélectionner un modèle..."
50
+ mapping_templates:
51
+ title: "Modèles de mapping"
52
+ new_template: "Nouveau modèle"
53
+ new_title: "Nouveau modèle de mapping"
54
+ edit_title: "Modifier le modèle de mapping"
55
+ back_to_templates: "Retour aux modèles"
56
+ back_to_imports: "Retour aux imports"
57
+ name: "Nom"
58
+ mappings: "Mappings"
59
+ columns_count:
60
+ one: "%{count} colonne"
61
+ other: "%{count} colonnes"
62
+ edit: "Modifier"
63
+ delete: "Supprimer"
64
+ no_templates: "Aucun modèle de mapping"
65
+ create_first: "Créer votre premier modèle"
66
+ form:
67
+ target_label: "Cible"
68
+ target_prompt: "Sélectionner une cible..."
69
+ name_label: "Nom du modèle"
70
+ name_placeholder: "ex. En-têtes français"
71
+ column_mappings: "Correspondances de colonnes"
72
+ file_header_placeholder: "En-tête du fichier"
73
+ field_prompt: "Sélectionner un champ..."
74
+ add_mapping: "+ Ajouter une correspondance"
75
+ create: "Créer le modèle"
76
+ update: "Mettre à jour le modèle"
77
+ cancel: "Annuler"
78
+ components:
79
+ summary_cards:
80
+ ready: "Prêt"
81
+ incomplete: "Incomplet"
82
+ missing: "Manquant"
83
+ duplicates: "Doublons"
84
+ results_summary:
85
+ completed: "Import terminé"
86
+ completed_with_errors: "Import terminé avec des erreurs"
87
+ imported: "Importés"
88
+ errors: "Erreurs"
89
+ skipped: "Ignorés"
90
+ duration: "Durée : %{duration}"
91
+ table:
92
+ line_number: "#"
93
+ status: "Statut"
94
+ errors: "Erreurs"
95
+ progress:
96
+ waiting: "En attente..."
97
+ extracting_headers: "Extraction des en-têtes..."
98
+ parsing: "Analyse des enregistrements..."
99
+ importing: "Import en cours..."
100
+ dry_running: "Essai à blanc..."
101
+ processing: "Traitement..."
102
+ pagination:
103
+ previous: "Précédent"
104
+ next: "Suivant"
105
+ page_info: "Page %{page} sur %{total}"
106
+ status_badge:
107
+ pending: "En attente"
108
+ extracting_headers: "Extraction"
109
+ mapping: "Mapping"
110
+ parsing: "Analyse"
111
+ previewing: "Aperçu"
112
+ importing: "Import"
113
+ completed: "Terminé"
114
+ failed: "Échoué"
115
+ dry_running: "Essai à blanc"
116
+ errors:
117
+ required: "%{label} est requis"
118
+ invalid_type: "%{label} : %{type} invalide"
119
+ max_records: "Le fichier contient %{count} enregistrements, dépasse le maximum de %{max}"
120
+ source_unavailable: "%{source} n'est pas disponible pour cette cible"
121
+ file_required: "doit être joint pour les imports %{source}"
122
+ file_too_large: "est trop volumineux (max %{max} Mo)"
123
+ invalid_content_type: "a un type de contenu invalide (%{type})"
data/config/routes.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  DataPorter::Engine.routes.draw do
4
+ resources :mapping_templates
5
+
4
6
  resources :imports, path: "/", only: %i[index new create show destroy] do
5
7
  member do
6
8
  post :parse
@@ -13,6 +15,4 @@ DataPorter::Engine.routes.draw do
13
15
  get :export_rejects
14
16
  end
15
17
  end
16
-
17
- resources :mapping_templates
18
18
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module ColumnTransformer
5
+ BUILT_IN = {
6
+ strip: :strip.to_proc,
7
+ downcase: :downcase.to_proc,
8
+ upcase: :upcase.to_proc,
9
+ titleize: :titleize.to_proc,
10
+ normalize_phone: ->(value) { value.gsub(/[\s\-().]+/, "") },
11
+ parse_date: ->(value) { Date.parse(value).iso8601 rescue value }, # rubocop:disable Style/RescueModifier
12
+ parse_boolean: ->(value) { %w[true 1 yes oui].include?(value.downcase) ? "true" : "false" },
13
+ parse_integer: ->(value) { Float(value, exception: false) ? value.to_f.to_i.to_s : value },
14
+ parse_decimal: ->(value) { Float(value, exception: false)&.to_s || value }
15
+ }.freeze
16
+
17
+ def self.apply(value, transformer_name)
18
+ return value if value.nil?
19
+
20
+ transformer = BUILT_IN[transformer_name] || custom_transformers[transformer_name]
21
+ raise Error, "Unknown transformer: #{transformer_name}" unless transformer
22
+
23
+ transformer.call(value.to_s)
24
+ end
25
+
26
+ def self.register(name, &block)
27
+ custom_transformers[name.to_sym] = block
28
+ end
29
+
30
+ def self.apply_all(record, columns)
31
+ columns.each do |col|
32
+ next if col.transform.empty?
33
+
34
+ key = resolve_key(record.data, col.name)
35
+ next unless key
36
+
37
+ col.transform.each do |t|
38
+ record.data[key] = apply(record.data[key], t)
39
+ end
40
+ end
41
+ end
42
+
43
+ def self.resolve_key(data, name)
44
+ return name.to_s if data.key?(name.to_s)
45
+ return name if data.key?(name)
46
+
47
+ nil
48
+ end
49
+
50
+ def self.custom_transformers
51
+ @custom_transformers ||= {}
52
+ end
53
+
54
+ private_class_method :custom_transformers, :resolve_key
55
+ end
56
+ end
@@ -36,7 +36,7 @@ module DataPorter
36
36
  data_data_porter__mapping_target: "columnSelect",
37
37
  data_action: "change->data-porter--mapping#onChange"
38
38
  ) do
39
- option(value: "") { "Skip this column" }
39
+ option(value: "") { I18n.t("data_porter.mapping.skip_column") }
40
40
  @target_fields.each { |label, value, required| render_field_option(label, value, required) }
41
41
  end
42
42
  end
@@ -54,7 +54,7 @@ module DataPorter
54
54
  return if @templates.empty?
55
55
 
56
56
  div(class: "dp-field") do
57
- label(class: "dp-label") { "Load Template" }
57
+ label(class: "dp-label") { I18n.t("data_porter.mapping.load_template") }
58
58
  render TemplateSelect.new(templates: @templates)
59
59
  end
60
60
  end
@@ -96,14 +96,14 @@ module DataPorter
96
96
  def render_template_checkbox
97
97
  label(style: "display: flex; align-items: center; gap: 0.5rem;") do
98
98
  input(type: "checkbox", name: "save_template", value: "1")
99
- span { "Save as template" }
99
+ span { I18n.t("data_porter.mapping.save_as_template") }
100
100
  end
101
101
  end
102
102
 
103
103
  def render_template_name_input
104
104
  input(
105
105
  type: "text", name: "template_name",
106
- placeholder: "Template name",
106
+ placeholder: I18n.t("data_porter.mapping.template_name_placeholder"),
107
107
  class: "dp-select",
108
108
  style: "margin-top: 0.5rem;"
109
109
  )
@@ -111,7 +111,7 @@ module DataPorter
111
111
 
112
112
  def render_actions
113
113
  div(class: "dp-actions") do
114
- button(type: "submit", class: "dp-btn dp-btn--primary") { "Continue" }
114
+ button(type: "submit", class: "dp-btn dp-btn--primary") { I18n.t("data_porter.mapping.continue") }
115
115
  end
116
116
  end
117
117
 
@@ -15,7 +15,7 @@ module DataPorter
15
15
  class: "dp-select dp-mapping-template",
16
16
  data_action: "change->data-porter--mapping#loadTemplate"
17
17
  ) do
18
- option(value: "") { "Select a template..." }
18
+ option(value: "") { I18n.t("data_porter.mapping.select_template") }
19
19
  @templates.each { |t| render_option(t) }
20
20
  end
21
21
  end
@@ -26,19 +26,27 @@ module DataPorter
26
26
  end
27
27
 
28
28
  def render_title
29
- h3(class: "dp-results__title") { success? ? "Import completed" : "Import completed with errors" }
29
+ key = success? ? "completed" : "completed_with_errors"
30
+ h3(class: "dp-results__title") { I18n.t("data_porter.components.results_summary.#{key}") }
30
31
  end
31
32
 
32
33
  def render_stats
33
34
  div(class: "dp-results__cards") do
34
- stat("dp-results__stat--success", @report.imported_count, "Imported")
35
- stat("dp-results__stat--error", @report.errored_count, "Errors")
36
- stat("dp-results__stat--warning", skipped_count, "Skipped") if skipped_count.positive?
35
+ stat("dp-results__stat--success", @report.imported_count,
36
+ I18n.t("data_porter.components.results_summary.imported"))
37
+ stat("dp-results__stat--error", @report.errored_count,
38
+ I18n.t("data_porter.components.results_summary.errors"))
39
+ if skipped_count.positive?
40
+ stat("dp-results__stat--warning", skipped_count,
41
+ I18n.t("data_porter.components.results_summary.skipped"))
42
+ end
37
43
  end
38
44
  end
39
45
 
40
46
  def render_duration
41
- div(class: "dp-results__duration") { "Duration: #{@duration}" }
47
+ div(class: "dp-results__duration") do
48
+ I18n.t("data_porter.components.results_summary.duration", duration: @duration)
49
+ end
42
50
  end
43
51
 
44
52
  def stat(css_class, count, label)