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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0439ba2634bbf47987015524141c3f961b54c362d234dcaeecc7811895c4ec34'
4
- data.tar.gz: b4b2daf3519743518b2119b75863ca83114db258599fa2026e1b7a2ae844668e
3
+ metadata.gz: 14b97e1d544f1169ed2e19763f1ae4ae1d15373cd809f5c6ddb46ebe286cc804
4
+ data.tar.gz: 5e7fd19fdf843d6f7721ef91d3147197d06d453771990408354a38d8758f647b
5
5
  SHA512:
6
- metadata.gz: b2ddc41b18ab043cbabb97518594ec27a0d1ec4ea0f4f1e542a6dde664c6914d6d1b61c609e6436d9a9bd741298c31766c623886eb3964a417b9a5ebec532b44
7
- data.tar.gz: 2fe7cc8d5910005f86b9198db007afc406bb186d933d0ff1a5e4c147fab191ade9386550fdbb5b85f4756c6795da979f81da5b4681782c027f7901763f1dd972
6
+ metadata.gz: b23a128b472327079631531f020fda16ea7399bb6c2f09437c8976ddf738800e1738c9afb9167db359c662d02e29ca7994bc7709d97ecb4a46e57df8e691a02f
7
+ data.tar.gz: 1bb9f3cc08b7fee3678638cfb047e191c495447afc93db7115c5a6c2914851f127ed9bf4ab7c44d110cc2585ae2de0fd9ee67d6a469082e9ede3d71315a9655a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,47 @@ 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.3.0] - 2026-02-20
9
+
10
+ ### Added
11
+
12
+ - **Webhooks** -- Per-target HTTP callbacks on import lifecycle events (`import.started`, `import.parsed`, `import.completed`, `import.failed`). Declarative DSL via `webhooks do webhook(url, events:, headers:, payload:) end`. HMAC-SHA256 request signing via `config.webhook_secret`. Async delivery via `WebhookJob` (fire-and-forget, 10s timeout). Custom payload lambdas and per-webhook headers supported
13
+
14
+ ### Changed
15
+
16
+ - 508 RSpec examples (up from 466), 0 failures
17
+
18
+ ## [2.2.0] - 2026-02-20
19
+
20
+ ### Added
21
+
22
+ - **Column transformers** -- Declarative per-column transformation pipeline via `transform: [:strip, :downcase]` in the columns DSL. Applied automatically before the target's `transform` method. Ships with 9 built-in transformers (`strip`, `downcase`, `upcase`, `titleize`, `normalize_phone`, `parse_date`, `parse_boolean`, `parse_integer`, `parse_decimal`). Custom transformers via `DataPorter::ColumnTransformer.register(:name) { |v| ... }`
23
+
24
+ ### Changed
25
+
26
+ - 466 RSpec examples (up from 438), 0 failures
27
+
28
+ ## [2.1.1] - 2026-02-20
29
+
30
+ ### Fixed
31
+
32
+ - **Route ordering** -- `mapping_templates` routes declared before the catch-all `imports` resource to prevent `/mapping_templates` from matching `imports#show`
33
+ - **Locale generator** -- Fixed `source_root` resolution so `copy_file` finds locale templates correctly
34
+ - **Locale generator** -- Added post-generation instructions (next steps message)
35
+
36
+ ## [2.1.0] - 2026-02-20
37
+
38
+ ### Added
39
+
40
+ - **i18n** -- All UI strings, error messages, and status labels are now translatable via Rails I18n. Ships with English and French locales (~100 keys each)
41
+ - **Locale generator** -- `rails g data_porter:locale fr` copies a locale file with all keys pre-filled for translation
42
+ - **Documentation site** -- MkDocs Material site with GitHub Pages deployment, search, dark mode, and full API reference
43
+ - **Progress labels via data attributes** -- Stimulus progress controller reads translated labels from the server instead of hardcoded JS strings
44
+
45
+ ### Changed
46
+
47
+ - 438 RSpec examples (up from 423), 0 failures
48
+
8
49
  ## [2.0.0] - 2026-02-19
9
50
 
10
51
  ### 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,77 @@
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
25
4
 
26
- ---
5
+ ### Bulk import
6
+
7
+ 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.
27
8
 
28
- ## Planned
9
+ ### Update & diff mode
29
10
 
30
- ### High Priority
11
+ 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.
31
12
 
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
13
+ ### Resume / retry on failure
37
14
 
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
15
+ 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.
43
16
 
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
17
+ ### API pagination
49
18
 
50
- ### Medium Priority
19
+ 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
20
 
52
- #### Column Transformers
53
- - Inline transform lambdas in the column DSL
54
- - Built-in transformers: `downcase`, `strip`, `normalize_phone`, `parse_date`
55
21
  ```ruby
56
- column :email, type: :email, transform: ->(v) { v.downcase.strip }
22
+ api_config do
23
+ endpoint "https://api.example.com/contacts"
24
+ pagination :cursor, param: "after", root: "data", next_key: "meta.next_cursor"
25
+ end
57
26
  ```
58
27
 
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
28
+ ### Import API (REST)
29
+
30
+ Headless REST API for programmatic imports:
31
+
32
+ - `POST /api/imports` — create import (multipart file upload or JSON payload)
33
+ - `GET /api/imports/:id` — status + results
34
+ - Auth via `config.api_authenticate` lambda (API key or Bearer token)
35
+ - Reuses existing job pipeline (parse, import, dry run)
36
+
37
+ ### View generator & theming
38
+
39
+ Customizable UI in two layers:
40
+
41
+ - **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`.
42
+ - **CSS theming** All styles use `--dp-*` custom properties. Host apps override variables to match their design system, no ERB changes needed.
43
+ - **Light / dark mode** — Two built-in presets toggled via `prefers-color-scheme` or a `.dp-dark` class.
44
+
45
+ ### Auto-map heuristics
46
+
47
+ 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.
48
+
49
+ ---
50
+
51
+ ## Ideas
52
+
53
+ ### Export (reverse workflow)
54
+
55
+ `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.
56
+
57
+ ### External connectors
58
+
59
+ Source plugins beyond local files and HTTP APIs:
60
+
61
+ - **Google Sheets** — OAuth2 + Sheets API, treat a spreadsheet as a source
62
+ - **SFTP** — Poll a remote directory for new files
63
+ - **AWS S3** — Watch a bucket/prefix for uploads
64
+
65
+ Each connector implements the `Sources::Base` interface. Installed as optional companion gems (`data_porter-google_sheets`, `data_porter-s3`).
66
+
67
+ ### Scheduled imports
68
+
69
+ 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.
70
+
71
+ ### Rollback
72
+
73
+ Undo a completed import by soft-deleting the created records. Confirmation step with summary of records to be reverted.
74
+
75
+ ### Dashboard & analytics
76
+
77
+ 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
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+
6
+ module DataPorter
7
+ class WebhookJob < ActiveJob::Base
8
+ queue_as { DataPorter.configuration.queue_name }
9
+
10
+ def perform(url, payload_json, headers = {})
11
+ uri = URI.parse(url)
12
+ request = build_request(uri, payload_json, headers)
13
+ execute_request(uri, request)
14
+ end
15
+
16
+ private
17
+
18
+ def build_request(uri, payload_json, headers)
19
+ request = Net::HTTP::Post.new(uri.request_uri)
20
+ request["Content-Type"] = "application/json"
21
+ headers.each { |key, value| request[key] = value }
22
+ sign_request(request, payload_json)
23
+ request.body = payload_json
24
+ request
25
+ end
26
+
27
+ def sign_request(request, payload_json)
28
+ secret = DataPorter.configuration.webhook_secret
29
+ return unless secret
30
+
31
+ digest = OpenSSL::HMAC.hexdigest("SHA256", secret, payload_json)
32
+ request["X-DataPorter-Signature"] = "sha256=#{digest}"
33
+ end
34
+
35
+ def execute_request(uri, request)
36
+ http = Net::HTTP.new(uri.host, uri.port)
37
+ http.use_ssl = uri.scheme == "https"
38
+ http.open_timeout = 10
39
+ http.read_timeout = 10
40
+ http.request(request)
41
+ rescue StandardError => e
42
+ Rails.logger.error("[DataPorter] Webhook delivery failed: #{e.message}")
43
+ end
44
+ end
45
+ 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");