data_porter 2.0.0 → 2.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +7 -1
  4. data/ROADMAP.md +84 -74
  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/views/data_porter/imports/index.html.erb +23 -23
  8. data/app/views/data_porter/imports/new.html.erb +11 -11
  9. data/app/views/data_porter/imports/show.html.erb +19 -19
  10. data/app/views/data_porter/mapping_templates/_form.html.erb +10 -10
  11. data/app/views/data_porter/mapping_templates/edit.html.erb +2 -2
  12. data/app/views/data_porter/mapping_templates/index.html.erb +10 -10
  13. data/app/views/data_porter/mapping_templates/new.html.erb +2 -2
  14. data/config/locales/en.yml +123 -0
  15. data/config/locales/fr.yml +123 -0
  16. data/config/routes.rb +2 -2
  17. data/lib/data_porter/components/mapping/column_row.rb +1 -1
  18. data/lib/data_porter/components/mapping/form.rb +4 -4
  19. data/lib/data_porter/components/mapping/template_select.rb +1 -1
  20. data/lib/data_porter/components/preview/results_summary.rb +13 -5
  21. data/lib/data_porter/components/preview/summary_cards.rb +5 -4
  22. data/lib/data_porter/components/preview/table.rb +3 -3
  23. data/lib/data_porter/components/progress/bar.rb +9 -2
  24. data/lib/data_porter/components/shared/pagination.rb +9 -5
  25. data/lib/data_porter/components/shared/status_badge.rb +3 -1
  26. data/lib/data_porter/engine.rb +4 -0
  27. data/lib/data_porter/orchestrator/record_builder.rb +1 -1
  28. data/lib/data_porter/record_validator.rb +2 -2
  29. data/lib/data_porter/version.rb +1 -1
  30. data/lib/generators/data_porter/locale/locale_generator.rb +42 -0
  31. data/mkdocs.yml +98 -0
  32. metadata +6 -3
  33. data/bookmarklet.md +0 -217
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0439ba2634bbf47987015524141c3f961b54c362d234dcaeecc7811895c4ec34'
4
- data.tar.gz: b4b2daf3519743518b2119b75863ca83114db258599fa2026e1b7a2ae844668e
3
+ metadata.gz: '04931dd3e74ae9a12b2225ddb1c5467022458013d49fb35a07282121296ec22b'
4
+ data.tar.gz: 4d0d04002509ae87358375f0df64bec0d8c134201131ead1a1a07460c9e19297
5
5
  SHA512:
6
- metadata.gz: b2ddc41b18ab043cbabb97518594ec27a0d1ec4ea0f4f1e542a6dde664c6914d6d1b61c609e6436d9a9bd741298c31766c623886eb3964a417b9a5ebec532b44
7
- data.tar.gz: 2fe7cc8d5910005f86b9198db007afc406bb186d933d0ff1a5e4c147fab191ade9386550fdbb5b85f4756c6795da979f81da5b4681782c027f7901763f1dd972
6
+ metadata.gz: 11ba16dcc818425722fc20e1a919833e4c4f77def52c07b46068707a1e511bb045bf7801edfb9b3186750fbf242a633d526e81e6b1bf3cd4b54b806c47e701aa
7
+ data.tar.gz: 4c99d6a1c4d04e4cee197199db9dd3517bc7d44b283e511a7289e46703ddf3d69f91a8cd7e4fb4f43ce5e8fdaaccbe805b5a649846d1c5e096a7803eafb07442
data/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.1.1] - 2026-02-20
9
+
10
+ ### Fixed
11
+
12
+ - **Route ordering** -- `mapping_templates` routes declared before the catch-all `imports` resource to prevent `/mapping_templates` from matching `imports#show`
13
+ - **Locale generator** -- Fixed `source_root` resolution so `copy_file` finds locale templates correctly
14
+ - **Locale generator** -- Added post-generation instructions (next steps message)
15
+
16
+ ## [2.1.0] - 2026-02-20
17
+
18
+ ### Added
19
+
20
+ - **i18n** -- All UI strings, error messages, and status labels are now translatable via Rails I18n. Ships with English and French locales (~100 keys each)
21
+ - **Locale generator** -- `rails g data_porter:locale fr` copies a locale file with all keys pre-filled for translation
22
+ - **Documentation site** -- MkDocs Material site with GitHub Pages deployment, search, dark mode, and full API reference
23
+ - **Progress labels via data attributes** -- Stimulus progress controller reads translated labels from the server instead of hardcoded JS strings
24
+
25
+ ### Changed
26
+
27
+ - 438 RSpec examples (up from 423), 0 failures
28
+
8
29
  ## [2.0.0] - 2026-02-19
9
30
 
10
31
  ### Breaking
data/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # DataPorter
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/data_porter.svg?icon=si%3Arubygems)](https://badge.fury.io/rb/data_porter)
4
+ [![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](https://seryllns.github.io/data_porter/)
5
+
3
6
  A mountable Rails engine for data import workflows: **Upload**, **Map**, **Preview**, **Import**.
4
7
 
5
8
  Supports CSV, JSON, XLSX, and API sources with a declarative DSL for defining import targets. Business-agnostic by design -- all domain logic lives in your host app.
@@ -98,6 +101,8 @@ pending -> parsing -> previewing -> importing -> completed
98
101
 
99
102
  ## Documentation
100
103
 
104
+ **[Full documentation on GitHub Pages](https://seryllns.github.io/data_porter/)**
105
+
101
106
  | Topic | Description |
102
107
  |---|---|
103
108
  | [Configuration](docs/CONFIGURATION.md) | All options, authentication, context builder, real-time updates |
@@ -119,6 +124,7 @@ pending -> parsing -> previewing -> importing -> completed
119
124
  | POST | `/imports/:id/parse` | Parse source |
120
125
  | POST | `/imports/:id/confirm` | Run import |
121
126
  | POST | `/imports/:id/cancel` | Cancel import |
127
+ | POST | `/imports/:id/back_to_mapping` | Reset to mapping step |
122
128
  | POST | `/imports/:id/dry_run` | Dry run validation |
123
129
  | GET | `/imports/:id/export_rejects` | Download rejects CSV |
124
130
  | | `/mapping_templates` | Full CRUD for templates |
@@ -129,7 +135,7 @@ pending -> parsing -> previewing -> importing -> completed
129
135
  git clone https://github.com/SerylLns/data_porter.git
130
136
  cd data_porter
131
137
  bin/setup
132
- bundle exec rspec # 413 specs
138
+ bundle exec rspec # 423 specs
133
139
  bundle exec rubocop # 0 offenses
134
140
  ```
135
141
 
data/ROADMAP.md CHANGED
@@ -1,89 +1,99 @@
1
1
  # Roadmap
2
2
 
3
- ## Completed
4
-
5
- ### v0.2.0 -- XLSX Source
6
- - ~~Parse `.xlsx` files natively via `creek` gem~~
7
- - ~~Sheet selector via `config["sheet_index"]`~~
8
- - ~~Same parsing pipeline as CSV~~
9
-
10
- ### v0.3.0 -- Interactive Column Mapping & Templates
11
- - ~~Mapping UI: each CSV/XLSX column header gets a dropdown to select the target field~~
12
- - ~~Save mapping as a reusable template (name + column-to-field pairs)~~
13
- - ~~Template selector that pre-fills all dropdowns at once~~
14
- - ~~Stored per-target so each import type has its own template library~~
15
- - ~~Header extraction step before parsing for file-based sources~~
16
- - ~~Dynamic mapping priority: user mapping > code mapping > auto-map~~
17
-
18
- ### v0.4.0 -- Standalone Engine UX
19
- - ~~Self-contained layout with Stimulus + Turbo Drive via CDN importmap~~
20
- - ~~Required field indication and duplicate mapping detection~~
21
- - ~~File validation on create for file-based sources~~
22
- - ~~Turbo Drive for instant page navigation~~
23
- - ~~Import details card on show page~~
24
- - ~~Improved template management UI~~
3
+ ## Next
4
+
5
+ ### Column transformers
6
+
7
+ Built-in transformation pipeline applied per-column before the target's `transform` method. Declarative DSL in the target:
8
+
9
+ ```ruby
10
+ columns do
11
+ column :email, type: :string, transform: [:strip, :downcase]
12
+ column :phone, type: :string, transform: [:strip, :normalize_phone]
13
+ column :born_on, type: :date, transform: [:parse_date]
14
+ end
15
+ ```
16
+
17
+ Ships with common transformers (`strip`, `downcase`, `titleize`, `normalize_phone`, `parse_date`). Custom transformers via a registry.
18
+
19
+ ### Webhooks
20
+
21
+ HTTP callbacks on import lifecycle events (started, completed, failed). Configurable per-target with URL, headers, and payload template. Enables integration with Slack notifications, CI pipelines, or external dashboards.
25
22
 
26
23
  ---
27
24
 
28
25
  ## Planned
29
26
 
30
- ### High Priority
27
+ ### Bulk import
28
+
29
+ High-volume import support using `insert_all` / `upsert_all` for batch persistence. Opt-in per target to bypass per-record `persist` calls, enabling 10-100x throughput for simple create/upsert scenarios. Configurable batch size, with fallback to per-record mode on conflict.
31
30
 
32
- #### Export (reverse workflow)
33
- - `ExportTarget` DSL mirroring the import Target
34
- - Define query scope, columns, and output format (CSV, JSON, XLSX)
35
- - Background job with progress bar (reuse existing ActionCable infrastructure)
36
- - Download link on completion
31
+ ### Update & diff mode
37
32
 
38
- #### Batch Import
39
- - Process large files in configurable batches (default: 1,000 records)
40
- - Use `insert_all` / `upsert_all` for bulk persistence
41
- - Granular progress: "12,000 / 150,000 records"
42
- - Memory-efficient streaming parser for CSV and XLSX
33
+ Support update (upsert) imports alongside create-only. Given a `deduplicate_by` key, detect existing records and show a diff preview: new records, changed fields (highlighted), unchanged rows. User confirms which changes to apply. Enables recurring data sync workflows.
43
34
 
44
- #### Scheduled Imports
45
- - Cron-like configuration in the Target DSL: `schedule "0 3 * * *"`
46
- - Recurring API source imports (fetch external data on a timer)
47
- - Dashboard for scheduled imports with last run status and next run time
48
- - Built on ActiveJob + `solid_queue` or host app's queue adapter
35
+ ### Resume / retry on failure
49
36
 
50
- ### Medium Priority
37
+ If an import fails mid-way (timeout, crash, transient error), resume from the last successful record instead of restarting from scratch. Track a checkpoint index in the report. Critical for large imports (5k+ records) where re-processing everything is not acceptable.
38
+
39
+ ### API pagination
40
+
41
+ Support paginated API sources. The current API source does a single GET, which works for small datasets but not for APIs returning thousands of records across multiple pages. Support offset, cursor, and link-header pagination strategies via `api_config`:
51
42
 
52
- #### Column Transformers
53
- - Inline transform lambdas in the column DSL
54
- - Built-in transformers: `downcase`, `strip`, `normalize_phone`, `parse_date`
55
43
  ```ruby
56
- column :email, type: :email, transform: ->(v) { v.downcase.strip }
44
+ api_config do
45
+ endpoint "https://api.example.com/contacts"
46
+ pagination :cursor, param: "after", root: "data", next_key: "meta.next_cursor"
47
+ end
57
48
  ```
58
49
 
59
- #### Auto-suggest Mapping
60
- - Fuzzy matching between file headers and target columns
61
- - Suggest mappings based on Levenshtein distance or string similarity
62
- - Pre-fill dropdowns with best guesses, user confirms
63
-
64
- #### Diff Mode
65
- - Compare incoming records with existing database data
66
- - Show what will be created, updated, or left unchanged
67
- - Visual diff on the preview step before confirming
68
- - Supports `deduplicate_by` keys for record matching
69
-
70
- #### Webhooks
71
- - Notify an external URL on import completion or failure
72
- - Configurable per-target or globally
73
- - JSON payload with import summary and error details
74
-
75
- #### Import API (REST)
76
- - `POST /data_porter/api/imports` to trigger imports programmatically
77
- - Accept file upload or source URL
78
- - JSON response with import ID for status polling
79
-
80
- ### Low Priority
81
-
82
- #### Dashboard Analytics
83
- - Stats: imports per week, error rate, average duration, top targets
84
- - Lightweight charts (inline SVG, no JS dependency)
85
-
86
- #### Rollback
87
- - Undo a completed import (soft-delete created records)
88
- - Uses `target_id` already tracked on each ImportRecord
89
- - Confirmation step with summary of records to be reverted
50
+ ### Import API (REST)
51
+
52
+ Headless REST API for programmatic imports:
53
+
54
+ - `POST /api/imports` — create import (multipart file upload or JSON payload)
55
+ - `GET /api/imports/:id` — status + results
56
+ - Auth via `config.api_authenticate` lambda (API key or Bearer token)
57
+ - Reuses existing job pipeline (parse, import, dry run)
58
+
59
+ ### View generator & theming
60
+
61
+ Customizable UI in two layers:
62
+
63
+ - **View generator** `rails g data_porter:views` copies the 7 ERB templates into the host app for structural customization (layout, buttons, sections). Similar to `devise:views`.
64
+ - **CSS theming** All styles use `--dp-*` custom properties. Host apps override variables to match their design system, no ERB changes needed.
65
+ - **Light / dark mode** — Two built-in presets toggled via `prefers-color-scheme` or a `.dp-dark` class.
66
+
67
+ ### Auto-map heuristics
68
+
69
+ Smart column mapping suggestions using tokenized header matching and synonym dictionaries. When a CSV has "E-mail Address", auto-suggest mapping to `:email`. Built-in synonyms for common patterns (phone → phone_number, first name → first_name). Configurable synonym lists per target.
70
+
71
+ ---
72
+
73
+ ## Ideas
74
+
75
+ ### Export (reverse workflow)
76
+
77
+ `ExportTarget` DSL mirroring the import Target. Define query scope, columns, and output format (CSV, JSON, XLSX). Background job with progress bar and download link on completion.
78
+
79
+ ### External connectors
80
+
81
+ Source plugins beyond local files and HTTP APIs:
82
+
83
+ - **Google Sheets** — OAuth2 + Sheets API, treat a spreadsheet as a source
84
+ - **SFTP** — Poll a remote directory for new files
85
+ - **AWS S3** — Watch a bucket/prefix for uploads
86
+
87
+ Each connector implements the `Sources::Base` interface. Installed as optional companion gems (`data_porter-google_sheets`, `data_porter-s3`).
88
+
89
+ ### Scheduled imports
90
+
91
+ Recurring imports from API or remote sources on a cron schedule. A target declares a schedule, and DataPorter automatically fetches and imports at the configured interval. Built on ActiveJob with configurable queue.
92
+
93
+ ### Rollback
94
+
95
+ Undo a completed import by soft-deleting the created records. Confirmation step with summary of records to be reverted.
96
+
97
+ ### Dashboard & analytics
98
+
99
+ Import statistics dashboard: success rates, average duration, records per import, most-used targets, failure trends. Mountable as an admin-only route.
@@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
4
  static targets = ["bar", "text", "label"]
5
- static values = { id: Number, url: String }
5
+ static values = { id: Number, url: String, labels: Object }
6
6
 
7
7
  connect() {
8
8
  this.poll()
@@ -32,8 +32,8 @@ export default class extends Controller {
32
32
 
33
33
  updateLabel(status) {
34
34
  if (!this.hasLabelTarget) return
35
- var labels = { pending: "Waiting...", extracting_headers: "Extracting headers...", parsing: "Parsing records...", importing: "Importing...", dry_running: "Dry run..." }
36
- this.labelTarget.textContent = labels[status] || "Processing..."
35
+ var labels = this.hasLabelsValue ? this.labelsValue : {}
36
+ this.labelTarget.textContent = labels[status] || labels["processing"] || status
37
37
  }
38
38
 
39
39
  disconnect() {
@@ -18,7 +18,7 @@ module DataPorter
18
18
  allowed = target._sources || DataPorter.configuration.enabled_sources
19
19
  return true if allowed.map(&:to_s).include?(@import.source_type.to_s)
20
20
 
21
- @import.errors.add(:source_type, "#{@import.source_type} is not available for this target")
21
+ @import.errors.add(:source_type, I18n.t("data_porter.errors.source_unavailable", source: @import.source_type))
22
22
  false
23
23
  end
24
24
 
@@ -26,7 +26,7 @@ module DataPorter
26
26
  return true unless %w[csv json xlsx].include?(@import.source_type)
27
27
  return true if @import.file.attached?
28
28
 
29
- @import.errors.add(:file, "must be attached for #{@import.source_type.upcase} imports")
29
+ @import.errors.add(:file, I18n.t("data_porter.errors.file_required", source: @import.source_type.upcase))
30
30
  false
31
31
  end
32
32
 
@@ -34,7 +34,7 @@ module DataPorter
34
34
  missing = missing_required_params
35
35
  return true if missing.empty?
36
36
 
37
- missing.each { |p| @import.errors.add(:base, "#{p.label} is required") }
37
+ missing.each { |p| @import.errors.add(:base, I18n.t("data_porter.errors.required", label: p.label)) }
38
38
  false
39
39
  end
40
40
 
@@ -55,7 +55,7 @@ module DataPorter
55
55
  max = DataPorter.configuration.max_file_size
56
56
  return true if @import.file.blob.byte_size <= max
57
57
 
58
- @import.errors.add(:file, "is too large (max #{max / 1.megabyte} MB)")
58
+ @import.errors.add(:file, I18n.t("data_porter.errors.file_too_large", max: max / 1.megabyte))
59
59
  false
60
60
  end
61
61
 
@@ -68,7 +68,7 @@ module DataPorter
68
68
  content_type = @import.file.blob.content_type
69
69
  return true if allowed.include?(content_type)
70
70
 
71
- @import.errors.add(:file, "has an invalid content type (#{content_type})")
71
+ @import.errors.add(:file, I18n.t("data_porter.errors.invalid_content_type", type: content_type))
72
72
  false
73
73
  end
74
74
  end
@@ -3,11 +3,11 @@
3
3
  data-data-porter--import-form-params-value="<%= @targets.map { |t| [t[:key], t[:params]] }.to_h.to_json %>"
4
4
  data-action="keydown@document->data-porter--import-form#closeModal">
5
5
  <div class="dp-header">
6
- <h1 class="dp-title">Imports</h1>
6
+ <h1 class="dp-title"><%= t("data_porter.imports.title") %></h1>
7
7
  <div class="dp-header__actions">
8
- <%= link_to "Mapping Templates", mapping_templates_path, class: "dp-btn dp-btn--secondary" %>
8
+ <%= link_to t("data_porter.mapping_templates.title"), mapping_templates_path, class: "dp-btn dp-btn--secondary" %>
9
9
  <button type="button" class="dp-btn dp-btn--primary" data-action="data-porter--import-form#openModal">
10
- New Import
10
+ <%= t("data_porter.imports.new_import") %>
11
11
  </button>
12
12
  </div>
13
13
  </div>
@@ -16,11 +16,11 @@
16
16
  <table class="dp-table">
17
17
  <thead>
18
18
  <tr>
19
- <th>ID</th>
20
- <th>Target</th>
21
- <th>Source</th>
22
- <th>Status</th>
23
- <th>Created</th>
19
+ <th><%= t("data_porter.imports.table.id") %></th>
20
+ <th><%= t("data_porter.imports.table.target") %></th>
21
+ <th><%= t("data_porter.imports.table.source") %></th>
22
+ <th><%= t("data_porter.imports.table.status") %></th>
23
+ <th><%= t("data_porter.imports.table.created") %></th>
24
24
  <th></th>
25
25
  </tr>
26
26
  </thead>
@@ -33,11 +33,11 @@
33
33
  <td><%= raw DataPorter::Components::Shared::StatusBadge.new(status: import.status).call %></td>
34
34
  <td><%= import.created_at&.strftime("%Y-%m-%d %H:%M") %></td>
35
35
  <td class="dp-table__actions">
36
- <%= link_to "View", import_path(import), class: "dp-btn dp-btn--sm dp-btn--secondary" %>
36
+ <%= link_to t("data_porter.imports.view"), import_path(import), class: "dp-btn dp-btn--sm dp-btn--secondary" %>
37
37
  <% if import.completed? || import.failed? %>
38
- <%= button_to "Delete", import_path(import),
38
+ <%= button_to t("data_porter.imports.delete"), import_path(import),
39
39
  method: :delete, class: "dp-btn dp-btn--sm dp-btn--danger",
40
- data: { turbo_confirm: "Delete this import?" } %>
40
+ data: { turbo_confirm: t("data_porter.imports.delete_confirm") } %>
41
41
  <% end %>
42
42
  </td>
43
43
  </tr>
@@ -47,9 +47,9 @@
47
47
  <% else %>
48
48
  <div class="dp-empty-state">
49
49
  <div class="dp-empty-state__icon">&#128230;</div>
50
- <p class="dp-empty-state__text">No imports yet</p>
50
+ <p class="dp-empty-state__text"><%= t("data_porter.imports.no_imports") %></p>
51
51
  <button type="button" class="dp-btn dp-btn--primary" data-action="data-porter--import-form#openModal">
52
- Create your first import
52
+ <%= t("data_porter.imports.create_first") %>
53
53
  </button>
54
54
  </div>
55
55
  <% end %>
@@ -58,16 +58,16 @@
58
58
  <div class="dp-modal__backdrop" data-action="click->data-porter--import-form#closeModalClick"></div>
59
59
  <div class="dp-modal__content">
60
60
  <div class="dp-modal__header">
61
- <h2 class="dp-modal__title">New Import</h2>
61
+ <h2 class="dp-modal__title"><%= t("data_porter.imports.new_import") %></h2>
62
62
  <button type="button" class="dp-modal__close" data-action="data-porter--import-form#closeModalClick">&times;</button>
63
63
  </div>
64
64
 
65
65
  <%= form_with model: DataPorter::DataImport.new, url: imports_path, class: "dp-modal__body", multipart: true, data: { turbo: false } do |f| %>
66
66
  <div class="dp-field">
67
- <%= f.label :target_key, "Target", class: "dp-label" %>
67
+ <%= f.label :target_key, t("data_porter.imports.form.target_label"), class: "dp-label" %>
68
68
  <%= f.select :target_key,
69
69
  @targets.map { |t| [t[:label], t[:key]] },
70
- { prompt: "Select a target..." },
70
+ { prompt: t("data_porter.imports.form.target_prompt") },
71
71
  class: "dp-select",
72
72
  data: {
73
73
  data_porter__import_form_target: "targetSelect",
@@ -78,10 +78,10 @@
78
78
  <div data-data-porter--import-form-target="paramsContainer"></div>
79
79
 
80
80
  <div class="dp-field">
81
- <%= f.label :source_type, "Source Type", class: "dp-label" %>
81
+ <%= f.label :source_type, t("data_porter.imports.form.source_label"), class: "dp-label" %>
82
82
  <%= f.select :source_type,
83
83
  DataPorter.configuration.enabled_sources.map { |s| [s.to_s.upcase, s] },
84
- { prompt: "Select source type..." },
84
+ { prompt: t("data_porter.imports.form.source_prompt") },
85
85
  class: "dp-select",
86
86
  data: {
87
87
  data_porter__import_form_target: "sourceSelect",
@@ -90,7 +90,7 @@
90
90
  </div>
91
91
 
92
92
  <div class="dp-field" data-data-porter--import-form-target="fileField">
93
- <%= f.label :file, "File", class: "dp-label" %>
93
+ <%= f.label :file, t("data_porter.imports.form.file_label"), class: "dp-label" %>
94
94
  <label class="dp-dropzone" data-data-porter--import-form-target="dropzone"
95
95
  data-action="dragover->data-porter--import-form#dragover dragleave->data-porter--import-form#dragleave drop->data-porter--import-form#drop">
96
96
  <input type="file" name="data_import[file]" class="dp-dropzone__input"
@@ -98,16 +98,16 @@
98
98
  data-action="data-porter--import-form#handleFile" />
99
99
  <div class="dp-dropzone__content">
100
100
  <div class="dp-dropzone__icon">&#128196;</div>
101
- <span class="dp-dropzone__text">Drop your file here or <strong>browse</strong></span>
102
- <span class="dp-dropzone__hint">CSV, JSON, or XLSX files accepted</span>
101
+ <span class="dp-dropzone__text"><%= raw t("data_porter.imports.form.dropzone_text") %></span>
102
+ <span class="dp-dropzone__hint"><%= t("data_porter.imports.form.dropzone_hint") %></span>
103
103
  </div>
104
104
  <div class="dp-dropzone__selected" data-data-porter--import-form-target="fileName" style="display: none;"></div>
105
105
  </label>
106
106
  </div>
107
107
 
108
108
  <div class="dp-modal__footer">
109
- <%= f.submit "Start Import", class: "dp-btn dp-btn--primary" %>
110
- <button type="button" class="dp-btn dp-btn--secondary" data-action="data-porter--import-form#closeModalClick">Cancel</button>
109
+ <%= f.submit t("data_porter.imports.start_import"), class: "dp-btn dp-btn--primary" %>
110
+ <button type="button" class="dp-btn dp-btn--secondary" data-action="data-porter--import-form#closeModalClick"><%= t("data_porter.imports.cancel") %></button>
111
111
  </div>
112
112
  <% end %>
113
113
  </div>
@@ -1,6 +1,6 @@
1
1
  <div class="data-porter">
2
2
  <div class="dp-header">
3
- <h1 class="dp-title">New Import</h1>
3
+ <h1 class="dp-title"><%= t("data_porter.imports.new_title") %></h1>
4
4
  </div>
5
5
 
6
6
  <% if @import.errors.any? %>
@@ -13,10 +13,10 @@
13
13
 
14
14
  <%= form_with model: @import, url: imports_path, class: "dp-form", multipart: true, data: { turbo: false } do |f| %>
15
15
  <div class="dp-field">
16
- <%= f.label :target_key, "Target", class: "dp-label" %>
16
+ <%= f.label :target_key, t("data_porter.imports.form.target_label"), class: "dp-label" %>
17
17
  <%= f.select :target_key,
18
18
  @targets.map { |t| [t[:label], t[:key]] },
19
- { prompt: "Select a target..." },
19
+ { prompt: t("data_porter.imports.form.target_prompt") },
20
20
  id: "dp-target-select-new",
21
21
  class: "dp-select",
22
22
  data: {
@@ -28,30 +28,30 @@
28
28
  <div id="dp-params-container" class="dp-params-container"></div>
29
29
 
30
30
  <div class="dp-field">
31
- <%= f.label :source_type, "Source Type", class: "dp-label" %>
31
+ <%= f.label :source_type, t("data_porter.imports.form.source_label"), class: "dp-label" %>
32
32
  <%= f.select :source_type,
33
33
  DataPorter.configuration.enabled_sources.map { |s| [s.to_s.upcase, s] },
34
- { prompt: "Select source type..." },
34
+ { prompt: t("data_porter.imports.form.source_prompt") },
35
35
  id: "dp-source-select-new",
36
36
  class: "dp-select" %>
37
37
  </div>
38
38
 
39
39
  <div id="dp-file-field-new" class="dp-field">
40
- <%= f.label :file, "File", class: "dp-label" %>
40
+ <%= f.label :file, t("data_porter.imports.form.file_label"), class: "dp-label" %>
41
41
  <label class="dp-dropzone" id="dp-dropzone-new">
42
42
  <input type="file" name="data_import[file]" id="dp-file-input-new" class="dp-dropzone__input" />
43
43
  <div class="dp-dropzone__content">
44
44
  <div class="dp-dropzone__icon">&#128196;</div>
45
- <span class="dp-dropzone__text">Drop your file here or <strong>browse</strong></span>
46
- <span class="dp-dropzone__hint">CSV, JSON, or XLSX files accepted</span>
45
+ <span class="dp-dropzone__text"><%= raw t("data_porter.imports.form.dropzone_text") %></span>
46
+ <span class="dp-dropzone__hint"><%= t("data_porter.imports.form.dropzone_hint") %></span>
47
47
  </div>
48
48
  <div class="dp-dropzone__selected" id="dp-file-name-new" style="display: none;"></div>
49
49
  </label>
50
50
  </div>
51
51
 
52
52
  <div class="dp-actions">
53
- <%= f.submit "Start Import", class: "dp-btn dp-btn--primary" %>
54
- <%= link_to "Cancel", imports_path, class: "dp-btn dp-btn--secondary" %>
53
+ <%= f.submit t("data_porter.imports.start_import"), class: "dp-btn dp-btn--primary" %>
54
+ <%= link_to t("data_porter.imports.cancel"), imports_path, class: "dp-btn dp-btn--secondary" %>
55
55
  </div>
56
56
  <% end %>
57
57
  </div>
@@ -84,7 +84,7 @@
84
84
  input.className = "dp-select";
85
85
  var blank = document.createElement("option");
86
86
  blank.value = "";
87
- blank.textContent = "Select...";
87
+ blank.textContent = "<%= j t("data_porter.imports.form.select_prompt") %>";
88
88
  input.appendChild(blank);
89
89
  p.collection.forEach(function(opt) {
90
90
  var o = document.createElement("option");
@@ -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
  })();