data_porter 0.1.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.
- checksums.yaml +7 -0
- data/.claude/commands/blog-status.md +10 -0
- data/.claude/commands/blog.md +109 -0
- data/.claude/commands/task-done.md +27 -0
- data/.claude/commands/tm/add-dependency.md +58 -0
- data/.claude/commands/tm/add-subtask.md +79 -0
- data/.claude/commands/tm/add-task.md +81 -0
- data/.claude/commands/tm/analyze-complexity.md +124 -0
- data/.claude/commands/tm/analyze-project.md +100 -0
- data/.claude/commands/tm/auto-implement-tasks.md +100 -0
- data/.claude/commands/tm/command-pipeline.md +80 -0
- data/.claude/commands/tm/complexity-report.md +120 -0
- data/.claude/commands/tm/convert-task-to-subtask.md +74 -0
- data/.claude/commands/tm/expand-all-tasks.md +52 -0
- data/.claude/commands/tm/expand-task.md +52 -0
- data/.claude/commands/tm/fix-dependencies.md +82 -0
- data/.claude/commands/tm/help.md +101 -0
- data/.claude/commands/tm/init-project-quick.md +49 -0
- data/.claude/commands/tm/init-project.md +53 -0
- data/.claude/commands/tm/install-taskmaster.md +118 -0
- data/.claude/commands/tm/learn.md +106 -0
- data/.claude/commands/tm/list-tasks-by-status.md +42 -0
- data/.claude/commands/tm/list-tasks-with-subtasks.md +30 -0
- data/.claude/commands/tm/list-tasks.md +46 -0
- data/.claude/commands/tm/next-task.md +69 -0
- data/.claude/commands/tm/parse-prd-with-research.md +51 -0
- data/.claude/commands/tm/parse-prd.md +52 -0
- data/.claude/commands/tm/project-status.md +67 -0
- data/.claude/commands/tm/quick-install-taskmaster.md +23 -0
- data/.claude/commands/tm/remove-all-subtasks.md +94 -0
- data/.claude/commands/tm/remove-dependency.md +65 -0
- data/.claude/commands/tm/remove-subtask.md +87 -0
- data/.claude/commands/tm/remove-subtasks.md +89 -0
- data/.claude/commands/tm/remove-task.md +110 -0
- data/.claude/commands/tm/setup-models.md +52 -0
- data/.claude/commands/tm/show-task.md +85 -0
- data/.claude/commands/tm/smart-workflow.md +58 -0
- data/.claude/commands/tm/sync-readme.md +120 -0
- data/.claude/commands/tm/tm-main.md +147 -0
- data/.claude/commands/tm/to-cancelled.md +58 -0
- data/.claude/commands/tm/to-deferred.md +50 -0
- data/.claude/commands/tm/to-done.md +47 -0
- data/.claude/commands/tm/to-in-progress.md +39 -0
- data/.claude/commands/tm/to-pending.md +35 -0
- data/.claude/commands/tm/to-review.md +43 -0
- data/.claude/commands/tm/update-single-task.md +122 -0
- data/.claude/commands/tm/update-task.md +75 -0
- data/.claude/commands/tm/update-tasks-from-id.md +111 -0
- data/.claude/commands/tm/validate-dependencies.md +72 -0
- data/.claude/commands/tm/view-models.md +52 -0
- data/.env.example +12 -0
- data/.mcp.json +24 -0
- data/.taskmaster/CLAUDE.md +435 -0
- data/.taskmaster/config.json +44 -0
- data/.taskmaster/docs/prd.txt +2044 -0
- data/.taskmaster/state.json +6 -0
- data/.taskmaster/tasks/task_001.md +19 -0
- data/.taskmaster/tasks/task_002.md +19 -0
- data/.taskmaster/tasks/task_003.md +19 -0
- data/.taskmaster/tasks/task_004.md +19 -0
- data/.taskmaster/tasks/task_005.md +19 -0
- data/.taskmaster/tasks/task_006.md +19 -0
- data/.taskmaster/tasks/task_007.md +19 -0
- data/.taskmaster/tasks/task_008.md +19 -0
- data/.taskmaster/tasks/task_009.md +19 -0
- data/.taskmaster/tasks/task_010.md +19 -0
- data/.taskmaster/tasks/task_011.md +19 -0
- data/.taskmaster/tasks/task_012.md +19 -0
- data/.taskmaster/tasks/task_013.md +19 -0
- data/.taskmaster/tasks/task_014.md +19 -0
- data/.taskmaster/tasks/task_015.md +19 -0
- data/.taskmaster/tasks/task_016.md +19 -0
- data/.taskmaster/tasks/task_017.md +19 -0
- data/.taskmaster/tasks/task_018.md +19 -0
- data/.taskmaster/tasks/task_019.md +19 -0
- data/.taskmaster/tasks/task_020.md +19 -0
- data/.taskmaster/tasks/tasks.json +299 -0
- data/.taskmaster/templates/example_prd.txt +47 -0
- data/.taskmaster/templates/example_prd_rpg.txt +511 -0
- data/CHANGELOG.md +29 -0
- data/CLAUDE.md +65 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/CONTRIBUTING.md +49 -0
- data/LICENSE +21 -0
- data/README.md +463 -0
- data/Rakefile +12 -0
- data/app/assets/stylesheets/data_porter/application.css +646 -0
- data/app/channels/data_porter/import_channel.rb +10 -0
- data/app/controllers/data_porter/imports_controller.rb +68 -0
- data/app/javascript/data_porter/progress_controller.js +33 -0
- data/app/jobs/data_porter/dry_run_job.rb +12 -0
- data/app/jobs/data_porter/import_job.rb +12 -0
- data/app/jobs/data_porter/parse_job.rb +12 -0
- data/app/models/data_porter/data_import.rb +49 -0
- data/app/views/data_porter/imports/index.html.erb +142 -0
- data/app/views/data_porter/imports/new.html.erb +88 -0
- data/app/views/data_porter/imports/show.html.erb +49 -0
- data/config/database.yml +3 -0
- data/config/routes.rb +12 -0
- data/docs/SPEC.md +2012 -0
- data/docs/UI.md +32 -0
- data/docs/blog/001-why-build-a-data-import-engine.md +166 -0
- data/docs/blog/002-scaffolding-a-rails-engine.md +188 -0
- data/docs/blog/003-configuration-dsl.md +222 -0
- data/docs/blog/004-store-model-jsonb.md +237 -0
- data/docs/blog/005-target-dsl.md +284 -0
- data/docs/blog/006-parsing-csv-sources.md +300 -0
- data/docs/blog/007-orchestrator.md +247 -0
- data/docs/blog/008-actioncable-stimulus.md +376 -0
- data/docs/blog/009-phlex-ui-components.md +446 -0
- data/docs/blog/010-controllers-routing.md +374 -0
- data/docs/blog/011-generators.md +364 -0
- data/docs/blog/012-json-api-sources.md +323 -0
- data/docs/blog/013-testing-rails-engine.md +618 -0
- data/docs/blog/014-dry-run.md +307 -0
- data/docs/blog/015-publishing-retro.md +264 -0
- data/docs/blog/016-erb-view-templates.md +431 -0
- data/docs/blog/017-showcase-final-retro.md +220 -0
- data/docs/blog/BACKLOG.md +8 -0
- data/docs/blog/SERIES.md +154 -0
- data/docs/screenshots/index-with-previewing.jpg +0 -0
- data/docs/screenshots/index.jpg +0 -0
- data/docs/screenshots/modal-new-import.jpg +0 -0
- data/docs/screenshots/preview.jpg +0 -0
- data/lib/data_porter/broadcaster.rb +29 -0
- data/lib/data_porter/components/base.rb +10 -0
- data/lib/data_porter/components/failure_alert.rb +20 -0
- data/lib/data_porter/components/preview_table.rb +54 -0
- data/lib/data_porter/components/progress_bar.rb +33 -0
- data/lib/data_porter/components/results_summary.rb +19 -0
- data/lib/data_porter/components/status_badge.rb +16 -0
- data/lib/data_porter/components/summary_cards.rb +30 -0
- data/lib/data_porter/components.rb +14 -0
- data/lib/data_porter/configuration.rb +25 -0
- data/lib/data_porter/dsl/api_config.rb +25 -0
- data/lib/data_porter/dsl/column.rb +17 -0
- data/lib/data_porter/engine.rb +15 -0
- data/lib/data_porter/orchestrator.rb +141 -0
- data/lib/data_porter/record_validator.rb +32 -0
- data/lib/data_porter/registry.rb +33 -0
- data/lib/data_porter/sources/api.rb +49 -0
- data/lib/data_porter/sources/base.rb +35 -0
- data/lib/data_porter/sources/csv.rb +43 -0
- data/lib/data_porter/sources/json.rb +45 -0
- data/lib/data_porter/sources.rb +20 -0
- data/lib/data_porter/store_models/error.rb +13 -0
- data/lib/data_porter/store_models/import_record.rb +52 -0
- data/lib/data_porter/store_models/report.rb +21 -0
- data/lib/data_porter/target.rb +89 -0
- data/lib/data_porter/type_validator.rb +46 -0
- data/lib/data_porter/version.rb +5 -0
- data/lib/data_porter.rb +32 -0
- data/lib/generators/data_porter/install/install_generator.rb +33 -0
- data/lib/generators/data_porter/install/templates/create_data_porter_imports.rb.erb +21 -0
- data/lib/generators/data_porter/install/templates/initializer.rb +30 -0
- data/lib/generators/data_porter/target/target_generator.rb +44 -0
- data/lib/generators/data_porter/target/templates/target.rb.tt +20 -0
- data/sig/data_porter.rbs +4 -0
- metadata +274 -0
data/docs/blog/SERIES.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
---
|
|
2
|
+
series: "Building DataPorter - A Data Import Engine for Rails"
|
|
3
|
+
author: ""
|
|
4
|
+
repo: ""
|
|
5
|
+
status: complete
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Series Plan
|
|
9
|
+
|
|
10
|
+
## Target audience
|
|
11
|
+
Ruby/Rails developers (intermediate+) interested in gem architecture, Rails engines, and clean DSL design.
|
|
12
|
+
|
|
13
|
+
## Series structure
|
|
14
|
+
Each article: ~5-8 min read, one clear concept, real code, decisions explained.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Articles
|
|
19
|
+
|
|
20
|
+
### Part 1 — Why build a data import engine?
|
|
21
|
+
- **Tasks:** None (intro)
|
|
22
|
+
- **Hook:** The repetitive import pattern in Rails apps
|
|
23
|
+
- **Covers:** Problem statement, existing solutions (maintenance_tasks, activeadmin_import), why a new gem, what we'll build (3-step workflow diagram)
|
|
24
|
+
- **Key decisions:** Mountable engine vs. lib, isolate_namespace, target audience
|
|
25
|
+
- **File:** `docs/blog/001-why-build-a-data-import-engine.md`
|
|
26
|
+
- **Status:** draft
|
|
27
|
+
|
|
28
|
+
### Part 2 — Scaffolding a Rails Engine gem
|
|
29
|
+
- **Tasks:** #1
|
|
30
|
+
- **Hook:** `bundle gem` is just the start
|
|
31
|
+
- **Covers:** Gem structure, Engine setup, isolate_namespace, directory tree, gemspec dependencies
|
|
32
|
+
- **Key decisions:** Why isolate_namespace, directory conventions, dependency choices (store_model, phlex, turbo)
|
|
33
|
+
- **File:** `docs/blog/002-scaffolding-a-rails-engine.md`
|
|
34
|
+
- **Status:** draft
|
|
35
|
+
|
|
36
|
+
### Part 3 — Configuration DSL: making the gem flexible
|
|
37
|
+
- **Tasks:** #2
|
|
38
|
+
- **Hook:** A good gem adapts to any host app
|
|
39
|
+
- **Covers:** Configuration singleton pattern, DSL with `configure` block, sensible defaults, context_builder lambda
|
|
40
|
+
- **Key decisions:** Why not Rails config, lambda vs block for context, what to make configurable vs. convention
|
|
41
|
+
- **File:** `docs/blog/003-configuration-dsl.md`
|
|
42
|
+
- **Status:** draft
|
|
43
|
+
|
|
44
|
+
### Part 4 — Modeling import data with StoreModel & JSONB
|
|
45
|
+
- **Tasks:** #3, #4
|
|
46
|
+
- **Hook:** Storing structured data without extra tables
|
|
47
|
+
- **Covers:** StoreModel gem, ImportRecord/Error/Report models, JSONB attributes, TypeValidator
|
|
48
|
+
- **Key decisions:** JSONB vs. separate tables, store_model vs. hand-rolled, validation strategy (column-level vs DB-level)
|
|
49
|
+
- **File:** `docs/blog/004-store-model-jsonb.md`
|
|
50
|
+
- **Status:** draft
|
|
51
|
+
|
|
52
|
+
### Part 5 — Designing a Target DSL
|
|
53
|
+
- **Tasks:** #5, #6
|
|
54
|
+
- **Hook:** One file = one import type, zero boilerplate
|
|
55
|
+
- **Covers:** Target base class, class-level DSL (label, model, columns, csv_mapping), Registry pattern, auto-discovery
|
|
56
|
+
- **Key decisions:** DSL design (class methods vs. instance), class_attribute vs. class instance vars, hook pattern for extensibility
|
|
57
|
+
- **File:** `docs/blog/005-target-dsl.md`
|
|
58
|
+
- **Status:** draft
|
|
59
|
+
|
|
60
|
+
### Part 6 — Parsing CSV data with Sources
|
|
61
|
+
- **Tasks:** #7, #8
|
|
62
|
+
- **Hook:** The first end-to-end flow
|
|
63
|
+
- **Covers:** DataImport model, migration, Source base class, CSV source, ActiveStorage integration, column mapping
|
|
64
|
+
- **Key decisions:** Polymorphic user association, enum state machine, auto-mapping vs explicit mapping
|
|
65
|
+
- **File:** `docs/blog/006-parsing-csv-sources.md`
|
|
66
|
+
- **Status:** draft
|
|
67
|
+
|
|
68
|
+
### Part 7 — The Orchestrator: coordinating the import workflow
|
|
69
|
+
- **Tasks:** #9, #10
|
|
70
|
+
- **Hook:** The brain of the engine
|
|
71
|
+
- **Covers:** Orchestrator class (parse! and import!), state transitions, error handling per-record, ActiveJob integration
|
|
72
|
+
- **Key decisions:** Why an orchestrator (not controller logic), transaction boundaries, error recovery strategy
|
|
73
|
+
- **File:** `docs/blog/007-orchestrator.md`
|
|
74
|
+
- **Status:** draft
|
|
75
|
+
|
|
76
|
+
### Part 8 — Real-time progress with ActionCable & Stimulus
|
|
77
|
+
- **Tasks:** #11, #14
|
|
78
|
+
- **Hook:** Users hate staring at a spinner with no feedback
|
|
79
|
+
- **Covers:** Broadcaster service, ImportChannel, Stimulus controller, progress bar updates, auto-reload on completion
|
|
80
|
+
- **Key decisions:** ActionCable vs. polling vs. SSE, channel naming strategy, Stimulus vs. Turbo Streams
|
|
81
|
+
- **File:** `docs/blog/008-actioncable-stimulus.md`
|
|
82
|
+
- **Status:** draft
|
|
83
|
+
|
|
84
|
+
### Part 9 — Building the UI with Phlex & Tailwind
|
|
85
|
+
- **Tasks:** #13
|
|
86
|
+
- **Hook:** Auto-generated preview tables from a DSL
|
|
87
|
+
- **Covers:** Phlex components, scoped Tailwind (dp- prefix), preview table with dynamic columns, status badges, CSS custom properties for theming
|
|
88
|
+
- **Key decisions:** Phlex vs. ERB/ViewComponent, Tailwind prefix strategy, how to not pollute host app styles
|
|
89
|
+
- **File:** `docs/blog/009-phlex-ui-components.md`
|
|
90
|
+
- **Status:** draft
|
|
91
|
+
|
|
92
|
+
### Part 10 — Controllers & routing in a Rails Engine
|
|
93
|
+
- **Tasks:** #12
|
|
94
|
+
- **Hook:** Engine controllers are tricky — here's the clean way
|
|
95
|
+
- **Covers:** ImportsController, inheriting from host's parent controller, engine routes, strong params, Turbo integration
|
|
96
|
+
- **Key decisions:** Dynamic parent controller inheritance, route namespacing, how auth flows from host to engine
|
|
97
|
+
- **File:** `docs/blog/010-controllers-routing.md`
|
|
98
|
+
- **Status:** draft
|
|
99
|
+
|
|
100
|
+
### Part 11 — Generators: install & target scaffolding
|
|
101
|
+
- **Tasks:** #15, #16
|
|
102
|
+
- **Hook:** Great gems install in one command
|
|
103
|
+
- **Covers:** Install generator (migration, initializer, routes), Target generator (column parsing, template), Rails::Generators API
|
|
104
|
+
- **Key decisions:** What to generate vs. what to configure, template format (ERB .tt), route injection strategy
|
|
105
|
+
- **File:** `docs/blog/011-generators.md`
|
|
106
|
+
- **Status:** draft
|
|
107
|
+
|
|
108
|
+
### Part 12 — Adding JSON & API sources
|
|
109
|
+
- **Tasks:** #18, #19
|
|
110
|
+
- **Hook:** CSV is just the beginning
|
|
111
|
+
- **Covers:** JSON source (file + raw text), API source (HTTP client, params injection), source plugin architecture
|
|
112
|
+
- **Key decisions:** Source abstraction design, how to handle auth headers, response_root extraction
|
|
113
|
+
- **File:** `docs/blog/012-json-api-sources.md`
|
|
114
|
+
- **Status:** draft
|
|
115
|
+
|
|
116
|
+
### Part 13 — Testing a Rails Engine with RSpec
|
|
117
|
+
- **Tasks:** #17
|
|
118
|
+
- **Hook:** Testing an engine is different from testing an app
|
|
119
|
+
- **Covers:** Dummy app setup, spec organization, testing StoreModels, mocking ActiveStorage, testing jobs, controller specs
|
|
120
|
+
- **Key decisions:** Dummy app vs. shared examples, factory vs. fixture, what to unit test vs. integration test
|
|
121
|
+
- **File:** `docs/blog/013-testing-rails-engine.md`
|
|
122
|
+
- **Status:** draft
|
|
123
|
+
|
|
124
|
+
### Part 14 — Dry Run: validate before you persist
|
|
125
|
+
- **Tasks:** #20
|
|
126
|
+
- **Hook:** Preview catches column errors, dry-run catches DB errors
|
|
127
|
+
- **Covers:** Transaction + rollback pattern, enriching records with DB errors, DryRunJob, UI integration
|
|
128
|
+
- **Key decisions:** Why two validation layers, transaction rollback approach, when to offer dry-run
|
|
129
|
+
- **File:** `docs/blog/014-dry-run.md`
|
|
130
|
+
- **Status:** draft
|
|
131
|
+
|
|
132
|
+
### Part 15 — Publishing the gem & retrospective
|
|
133
|
+
- **Tasks:** None (wrap-up)
|
|
134
|
+
- **Hook:** From idea to rubygems.org
|
|
135
|
+
- **Covers:** Gemspec final polish, CHANGELOG, versioning, publishing, series recap, what worked, what we'd do differently
|
|
136
|
+
- **Key decisions:** Semantic versioning strategy, open source considerations
|
|
137
|
+
- **File:** `docs/blog/015-publishing-retro.md`
|
|
138
|
+
- **Status:** draft
|
|
139
|
+
|
|
140
|
+
### Part 16 — ERB View Templates: Composing Phlex Components
|
|
141
|
+
- **Tasks:** None (view layer completion)
|
|
142
|
+
- **Hook:** Phlex components are pure Ruby. ERB is what the browser sees. The glue is `.call`.
|
|
143
|
+
- **Covers:** ERB templates composing Phlex, has_one_attached bugfix, ActiveStorage test setup, view testing in Rails 8, plain CSS stylesheet
|
|
144
|
+
- **Key decisions:** raw component.call vs phlex-rails, plain CSS vs build step, view test infrastructure
|
|
145
|
+
- **File:** `docs/blog/016-erb-view-templates.md`
|
|
146
|
+
- **Status:** draft
|
|
147
|
+
|
|
148
|
+
### Part 17 — Showcase & Final Retrospective
|
|
149
|
+
- **Tasks:** None (series finale)
|
|
150
|
+
- **Hook:** 17 articles, 22 components, one complete gem. DataPorter in action.
|
|
151
|
+
- **Covers:** Full workflow walkthrough with screenshots, architecture overview, series recap, lessons learned, what's next
|
|
152
|
+
- **Key decisions:** N/A (retrospective)
|
|
153
|
+
- **File:** `docs/blog/017-showcase-final-retro.md`
|
|
154
|
+
- **Status:** draft
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataPorter
|
|
4
|
+
class Broadcaster
|
|
5
|
+
def initialize(import_id)
|
|
6
|
+
prefix = DataPorter.configuration.cable_channel_prefix
|
|
7
|
+
@channel = "#{prefix}/imports/#{import_id}"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def progress(current, total)
|
|
11
|
+
percentage = ((current.to_f / total) * 100).round
|
|
12
|
+
broadcast(status: :processing, percentage: percentage, current: current, total: total)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def success
|
|
16
|
+
broadcast(status: :success)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def failure(message)
|
|
20
|
+
broadcast(status: :failure, error: message)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def broadcast(message)
|
|
26
|
+
ActionCable.server.broadcast(@channel, message)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataPorter
|
|
4
|
+
module Components
|
|
5
|
+
class FailureAlert < Base
|
|
6
|
+
def initialize(report:)
|
|
7
|
+
super()
|
|
8
|
+
@report = report
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def view_template
|
|
12
|
+
div(class: "dp-alert dp-alert--danger") do
|
|
13
|
+
@report.error_reports.each do |err|
|
|
14
|
+
p { err.message }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataPorter
|
|
4
|
+
module Components
|
|
5
|
+
class PreviewTable < Base
|
|
6
|
+
def initialize(columns:, records:)
|
|
7
|
+
super()
|
|
8
|
+
@columns = columns
|
|
9
|
+
@records = records
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def view_template
|
|
13
|
+
div(class: "dp-preview-table") do
|
|
14
|
+
table(class: "dp-table") do
|
|
15
|
+
render_header
|
|
16
|
+
render_body
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def render_header
|
|
24
|
+
thead do
|
|
25
|
+
tr do
|
|
26
|
+
th { "#" }
|
|
27
|
+
th { "Status" }
|
|
28
|
+
@columns.each { |col| th { col.label } }
|
|
29
|
+
th { "Errors" }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def render_body
|
|
35
|
+
tbody do
|
|
36
|
+
@records.each { |record| render_row(record) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def render_row(record)
|
|
41
|
+
tr(class: "dp-row--#{record.status}") do
|
|
42
|
+
td { record.line_number.to_s }
|
|
43
|
+
td { record.status }
|
|
44
|
+
@columns.each { |col| td { record.data[col.name.to_s].to_s } }
|
|
45
|
+
td(class: "dp-errors") { error_messages(record) }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def error_messages(record)
|
|
50
|
+
record.errors_list.map(&:message).join(", ")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataPorter
|
|
4
|
+
module Components
|
|
5
|
+
class ProgressBar < Base
|
|
6
|
+
def initialize(import_id:)
|
|
7
|
+
super()
|
|
8
|
+
@import_id = import_id
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def view_template
|
|
12
|
+
div(class: "dp-progress", **stimulus_controller_attrs) do
|
|
13
|
+
render_bar
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def render_bar
|
|
20
|
+
div(class: "dp-progress-bar", data_data_porter__progress_target: "bar", style: "width: 0%") do
|
|
21
|
+
span(data_data_porter__progress_target: "text") { "0%" }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def stimulus_controller_attrs
|
|
26
|
+
{
|
|
27
|
+
data_controller: "data-porter--progress",
|
|
28
|
+
data_data_porter__progress_id_value: @import_id.to_s
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataPorter
|
|
4
|
+
module Components
|
|
5
|
+
class ResultsSummary < Base
|
|
6
|
+
def initialize(report:)
|
|
7
|
+
super()
|
|
8
|
+
@report = report
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def view_template
|
|
12
|
+
div(class: "dp-results") do
|
|
13
|
+
p { "Created: #{@report.imported_count}" }
|
|
14
|
+
p { "Errors: #{@report.errored_count}" }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataPorter
|
|
4
|
+
module Components
|
|
5
|
+
class StatusBadge < Base
|
|
6
|
+
def initialize(status:)
|
|
7
|
+
super()
|
|
8
|
+
@status = status.to_s
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def view_template
|
|
12
|
+
span(class: "dp-badge dp-badge--#{@status}") { @status.capitalize }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataPorter
|
|
4
|
+
module Components
|
|
5
|
+
class SummaryCards < Base
|
|
6
|
+
def initialize(report:)
|
|
7
|
+
super()
|
|
8
|
+
@report = report
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def view_template
|
|
12
|
+
div(class: "dp-summary-cards") do
|
|
13
|
+
card("dp-card--complete", @report.complete_count, "Ready")
|
|
14
|
+
card("dp-card--partial", @report.partial_count, "Incomplete")
|
|
15
|
+
card("dp-card--missing", @report.missing_count, "Missing")
|
|
16
|
+
card("dp-card--duplicate", @report.duplicate_count, "Duplicates")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def card(css_class, count, label)
|
|
23
|
+
div(class: "dp-card #{css_class}") do
|
|
24
|
+
strong { count.to_s }
|
|
25
|
+
plain " #{label}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "components/base"
|
|
4
|
+
require_relative "components/status_badge"
|
|
5
|
+
require_relative "components/summary_cards"
|
|
6
|
+
require_relative "components/preview_table"
|
|
7
|
+
require_relative "components/progress_bar"
|
|
8
|
+
require_relative "components/results_summary"
|
|
9
|
+
require_relative "components/failure_alert"
|
|
10
|
+
|
|
11
|
+
module DataPorter
|
|
12
|
+
module Components
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataPorter
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :parent_controller,
|
|
6
|
+
:queue_name,
|
|
7
|
+
:storage_service,
|
|
8
|
+
:cable_channel_prefix,
|
|
9
|
+
:context_builder,
|
|
10
|
+
:preview_limit,
|
|
11
|
+
:enabled_sources,
|
|
12
|
+
:scope
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@parent_controller = "ApplicationController"
|
|
16
|
+
@queue_name = :imports
|
|
17
|
+
@storage_service = :local
|
|
18
|
+
@cable_channel_prefix = "data_porter"
|
|
19
|
+
@context_builder = nil
|
|
20
|
+
@preview_limit = 500
|
|
21
|
+
@enabled_sources = %i[csv json api]
|
|
22
|
+
@scope = nil
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataPorter
|
|
4
|
+
module DSL
|
|
5
|
+
class ApiConfig
|
|
6
|
+
def endpoint(value = nil)
|
|
7
|
+
return @endpoint if value.nil?
|
|
8
|
+
|
|
9
|
+
@endpoint = value
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def headers(value = nil)
|
|
13
|
+
return @headers if value.nil?
|
|
14
|
+
|
|
15
|
+
@headers = value
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def response_root(value = nil)
|
|
19
|
+
return @response_root if value.nil?
|
|
20
|
+
|
|
21
|
+
@response_root = value
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataPorter
|
|
4
|
+
module DSL
|
|
5
|
+
Column = Struct.new(:name, :type, :required, :label, :options, keyword_init: true) do
|
|
6
|
+
def initialize(name:, type: :string, required: false, label: nil, **options)
|
|
7
|
+
super(
|
|
8
|
+
name: name.to_sym,
|
|
9
|
+
type: type.to_sym,
|
|
10
|
+
required: required,
|
|
11
|
+
label: label || name.to_s.humanize,
|
|
12
|
+
options: options
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataPorter
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace DataPorter
|
|
6
|
+
|
|
7
|
+
initializer "data_porter.assets.precompile" do |app|
|
|
8
|
+
app.config.assets.precompile += %w[data_porter/application.css] if app.config.respond_to?(:assets)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
config.to_prepare do
|
|
12
|
+
Dir[Rails.root.join("app/importers/*_target.rb")].each { |f| require f }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataPorter
|
|
4
|
+
class Orchestrator
|
|
5
|
+
def initialize(data_import, content: nil)
|
|
6
|
+
@data_import = data_import
|
|
7
|
+
@target = data_import.target_class.new
|
|
8
|
+
@source_options = { content: content }.compact
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def parse!
|
|
12
|
+
@data_import.parsing!
|
|
13
|
+
records = build_records
|
|
14
|
+
@data_import.update!(records: records, status: :previewing)
|
|
15
|
+
build_report
|
|
16
|
+
rescue StandardError => e
|
|
17
|
+
handle_failure(e)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def import!
|
|
21
|
+
@data_import.importing!
|
|
22
|
+
results = import_records
|
|
23
|
+
update_import_report(results)
|
|
24
|
+
@target.after_import(results, context: build_context)
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
handle_failure(e)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def dry_run!
|
|
30
|
+
@data_import.dry_running!
|
|
31
|
+
run_dry_run_records
|
|
32
|
+
@data_import.update!(status: :previewing)
|
|
33
|
+
build_report
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
handle_failure(e)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_records
|
|
41
|
+
source = @data_import.source_class.new(@data_import, **@source_options)
|
|
42
|
+
raw_rows = source.fetch
|
|
43
|
+
columns = @target.class._columns || []
|
|
44
|
+
validator = RecordValidator.new(columns)
|
|
45
|
+
|
|
46
|
+
raw_rows.each_with_index.map do |row, index|
|
|
47
|
+
build_record(row, index, columns, validator)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def build_record(row, index, columns, validator)
|
|
52
|
+
record = StoreModels::ImportRecord.new(
|
|
53
|
+
line_number: index + 1,
|
|
54
|
+
data: extract_data(row, columns)
|
|
55
|
+
)
|
|
56
|
+
record = @target.transform(record)
|
|
57
|
+
@target.validate(record)
|
|
58
|
+
validator.validate(record)
|
|
59
|
+
record.determine_status!
|
|
60
|
+
record
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def extract_data(row, columns)
|
|
64
|
+
columns.each_with_object({}) do |col, hash|
|
|
65
|
+
hash[col.name] = row[col.name] || row[col.name.to_s]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def run_dry_run_records
|
|
70
|
+
records = @data_import.records
|
|
71
|
+
importable = records.select(&:importable?)
|
|
72
|
+
context = build_context
|
|
73
|
+
|
|
74
|
+
importable.each do |record|
|
|
75
|
+
dry_run_record(record, context)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@data_import.records_will_change!
|
|
79
|
+
@data_import.update!(records: records)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def dry_run_record(record, context)
|
|
83
|
+
@target.persist(record, context: context)
|
|
84
|
+
record.dry_run_passed = true
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
record.dry_run_passed = false
|
|
87
|
+
record.add_error(e.message)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def import_records
|
|
91
|
+
importable = @data_import.importable_records
|
|
92
|
+
context = build_context
|
|
93
|
+
results = { created: 0, errored: 0 }
|
|
94
|
+
|
|
95
|
+
importable.each do |record|
|
|
96
|
+
persist_record(record, context, results)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
@data_import.update!(status: :completed)
|
|
100
|
+
results
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def persist_record(record, context, results)
|
|
104
|
+
@target.persist(record, context: context)
|
|
105
|
+
results[:created] += 1
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
record.add_error(e.message)
|
|
108
|
+
@target.on_error(record, e, context: context)
|
|
109
|
+
results[:errored] += 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def update_import_report(results)
|
|
113
|
+
report = @data_import.report || StoreModels::Report.new
|
|
114
|
+
report.imported_count = results[:created]
|
|
115
|
+
report.errored_count = results[:errored]
|
|
116
|
+
@data_import.update!(report: report)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_report
|
|
120
|
+
summary = @data_import.records_summary
|
|
121
|
+
report = StoreModels::Report.new(
|
|
122
|
+
records_count: @data_import.records.size,
|
|
123
|
+
complete_count: summary["complete"] || 0,
|
|
124
|
+
partial_count: summary["partial"] || 0,
|
|
125
|
+
missing_count: summary["missing"] || 0
|
|
126
|
+
)
|
|
127
|
+
@data_import.update!(report: report)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def build_context
|
|
131
|
+
DataPorter.configuration.context_builder&.call(@data_import)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def handle_failure(error)
|
|
135
|
+
report = StoreModels::Report.new(
|
|
136
|
+
error_reports: [StoreModels::Error.new(message: error.message)]
|
|
137
|
+
)
|
|
138
|
+
@data_import.update!(status: :failed, report: report)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DataPorter
|
|
4
|
+
class RecordValidator
|
|
5
|
+
def initialize(columns)
|
|
6
|
+
@columns = columns
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def validate(record)
|
|
10
|
+
@columns.each do |col|
|
|
11
|
+
value = record.data[col.name]
|
|
12
|
+
validate_required(record, col, value)
|
|
13
|
+
validate_type(record, col, value)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def validate_required(record, col, value)
|
|
20
|
+
return unless col.required && value.to_s.strip.empty?
|
|
21
|
+
|
|
22
|
+
record.add_error("#{col.label} is required")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate_type(record, col, value)
|
|
26
|
+
return if value.to_s.strip.empty?
|
|
27
|
+
return if TypeValidator.valid?(value, col.type, col.options)
|
|
28
|
+
|
|
29
|
+
record.add_error("#{col.label}: invalid #{col.type}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|