ixport 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.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +89 -0
  4. data/Rakefile +13 -0
  5. data/app/controllers/ixport/application_controller.rb +23 -0
  6. data/app/controllers/ixport/imports_controller.rb +36 -0
  7. data/app/controllers/ixport/mappings_controller.rb +16 -0
  8. data/app/controllers/ixport/previews_controller.rb +16 -0
  9. data/app/controllers/ixport/runs_controller.rb +12 -0
  10. data/app/models/ixport/application_record.rb +7 -0
  11. data/app/models/ixport/import.rb +68 -0
  12. data/app/models/ixport/import_refusal.rb +9 -0
  13. data/app/models/ixport/row_walk.rb +48 -0
  14. data/app/views/ixport/imports/index.html.erb +10 -0
  15. data/app/views/ixport/imports/new.html.erb +11 -0
  16. data/app/views/ixport/imports/show.html.erb +20 -0
  17. data/app/views/ixport/mappings/show.html.erb +12 -0
  18. data/app/views/ixport/previews/show.html.erb +35 -0
  19. data/config/routes.rb +8 -0
  20. data/lib/generators/ixport/install/install_generator.rb +21 -0
  21. data/lib/generators/ixport/install/templates/create_ixport_import_refusals.rb.erb +10 -0
  22. data/lib/generators/ixport/install/templates/create_ixport_imports.rb.erb +14 -0
  23. data/lib/ixport/configuration.rb +27 -0
  24. data/lib/ixport/engine.rb +9 -0
  25. data/lib/ixport/importer.rb +30 -0
  26. data/lib/ixport/registrations.rb +31 -0
  27. data/lib/ixport/version.rb +5 -0
  28. data/lib/ixport.rb +9 -0
  29. data/the_local/agents/ixport-develop.md +104 -0
  30. data/the_local/agents/ixport-info.md +59 -0
  31. data/the_local/agents/ixport-install.md +82 -0
  32. data/the_local/interface.yml +55 -0
  33. metadata +148 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 81097e7e2778b75a3e0a8e036151b7cc6627c50bd929f86129a78c429083cb38
4
+ data.tar.gz: 5d16aede3fdae08b99f79afe2db7933b5b171ee92efa6c4998224269a5742191
5
+ SHA512:
6
+ metadata.gz: d874135a600df60b6c2c98a56d41160d171a132eada93fad35de5494792afc256fc63132a905e57577653025b0b2b69404548d9fd6024613e55057a6f752126d
7
+ data.tar.gz: a690f23002ff143c8bc1a2906b213df52f358e6e40d6405e3a7686906de653d1d8af5d90441ff3d11b2b62c79337a367acdaba46a8ec487abeeebdf4d702b419
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2026 Tyler Schneider
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # ixport
2
+
3
+ CSV import and export for Rails apps. An app registers the kinds of CSV a person can import and the fields each one maps to.
4
+
5
+ ## Installation
6
+
7
+ Add the gem and run its install generator, which copies the migrations for its imports table and the table of rows an import refused:
8
+
9
+ ```ruby
10
+ gem "ixport"
11
+ ```
12
+
13
+ ```bash
14
+ bundle install
15
+ bin/rails generate ixport:install
16
+ bin/rails db:migrate
17
+ ```
18
+
19
+ Mount the engine at the path its pages should live under:
20
+
21
+ ```ruby
22
+ mount Ixport::Engine => "/data_imports"
23
+ ```
24
+
25
+ ## Configuration
26
+
27
+ Every setting has a default that matches a Jumpstart app, so set only what differs in your app:
28
+
29
+ ```ruby
30
+ Ixport.configure do |config|
31
+ config.authentication_method = :authenticate_user! # runs before every ixport page
32
+ config.current_person_method = :current_user # returns the person who starts and views imports
33
+ config.current_account_method = :current_account # returns the account an import belongs to
34
+ config.layout = "application" # the layout ixport's pages are drawn in
35
+ config.inline_row_limit = 1_000 # the most rows an import may have when it runs inside the request
36
+ end
37
+ ```
38
+
39
+ ## Mapped imports and importers
40
+
41
+ A mapped import is a kind of CSV a person can import into one of the app's records. Register each one in an initializer with its key, its title and the name of its importer class:
42
+
43
+ ```ruby
44
+ Ixport.register_import :products, title: "Products", importer: "ProductImporter"
45
+ ```
46
+
47
+ An app whose registered importer class does not exist fails to boot, with a message naming the class.
48
+
49
+ The engine's root page lists every registered import by its title, each linking to its upload page.
50
+
51
+ An importer lists the fields a person maps the file's columns to, each with the label the person sees. It names the one field rows are matched on, and the records a row may match, which it reaches through the `person` and `account` the import is for:
52
+
53
+ ```ruby
54
+ class ProductImporter < Ixport::Importer
55
+ field :sku, label: "SKU"
56
+ field :price_cents, label: "Price"
57
+ match_on :sku
58
+
59
+ def records
60
+ account.products
61
+ end
62
+ end
63
+ ```
64
+
65
+ Each field's key is the record's attribute name. ixport looks up every match value in the file with one query against `records`. A row whose match value matches one of those records updates it, and a row that matches nothing adds a new record built from `records`. A row whose match value is blank, or whose file has no column mapped to the match field, always adds a record.
66
+
67
+ ## Uploading and mapping a file
68
+
69
+ From the engine's root page, a person picks a registered import and uploads a CSV for it. The upload is refused, with a message on the upload page, when the file has no header row or has more rows than `config.inline_row_limit`.
70
+
71
+ After uploading, the person matches each column of the file's header row to one of the importer's fields, by its label, or skips it. Saving the mapping moves them on to the preview.
72
+
73
+ The preview shows how many rows will add a record, update one or be refused, before anything is saved. It lists each row that will update a record and each refused row, by its row number in the file, with the reason the record gives for a refusal. A row is refused when the record it builds is not valid. Viewing the preview saves nothing, and the person can go back from it to change the mapping.
74
+
75
+ ## Running an import
76
+
77
+ The person runs the import from the preview, and it runs inside the request. Each row that passes the record's validations is saved, updating the record it matches or adding a new one. Each row that fails them is refused and kept with its row number and reason, and the record it matched is left unchanged.
78
+
79
+ The import's result page then shows how many rows were added and how many updated, and lists each refused row. It shows the same thing on every later visit. A finished import offers no Run button, its preview leads to its result page, and running it again adds or changes nothing.
80
+
81
+ An import belongs to the person and account that uploaded it, and anyone else gets not found when they open it.
82
+
83
+ ## Claude Code agents
84
+
85
+ ixport ships its own Claude Code agents through [the_local](https://github.com/DYB-Development/the_local). An app that lists ixport in its Gemfile and runs `bundle exec the_local install` gets them in `.claude/agents/`.
86
+
87
+ ## License
88
+
89
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test"
7
+ t.libs << "lib"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ end
10
+
11
+ task default: :test
12
+
13
+ require "the_local/rake"
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ixport
4
+ class ApplicationController < ::ApplicationController
5
+ before_action { send(Ixport.configuration.authentication_method) }
6
+ layout -> { Ixport.configuration.layout }
7
+
8
+ private
9
+
10
+ def find_import
11
+ @import = Import.find_by(id: params[:import_id] || params[:id], person: ixport_person, account: ixport_account)
12
+ head :not_found unless @import
13
+ end
14
+
15
+ def ixport_person
16
+ send(Ixport.configuration.current_person_method)
17
+ end
18
+
19
+ def ixport_account
20
+ send(Ixport.configuration.current_account_method)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ixport
4
+ class ImportsController < ApplicationController
5
+ before_action :find_registration, only: %i[new create]
6
+ before_action :find_import, only: :show
7
+
8
+ def index
9
+ @registrations = Ixport.registrations.values
10
+ end
11
+
12
+ def new
13
+ end
14
+
15
+ def show
16
+ redirect_to import_preview_path(@import) unless @import.finished?
17
+ end
18
+
19
+ def create
20
+ import = Import.new(key: @registration.key, person: ixport_person, account: ixport_account, file: params[:file])
21
+ if import.save
22
+ redirect_to import_mapping_path(import)
23
+ else
24
+ @error = import.errors[:file].first
25
+ render :new, status: :unprocessable_entity
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def find_registration
32
+ @registration = Ixport.registrations[params[:key].to_s.to_sym]
33
+ head :not_found unless @registration
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ixport
4
+ class MappingsController < ApplicationController
5
+ before_action :find_import
6
+
7
+ def show
8
+ @fields = @import.importer_class.fields
9
+ end
10
+
11
+ def update
12
+ @import.update!(mapping: params.require(:mapping).permit(*@import.headers.each_index.map(&:to_s)).to_h.compact_blank)
13
+ redirect_to import_preview_path(@import)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ixport
4
+ class PreviewsController < ApplicationController
5
+ before_action :find_import
6
+
7
+ def show
8
+ return redirect_to import_path(@import) if @import.finished?
9
+
10
+ rows = RowWalk.new(@import, @import.importer_class.new(person: ixport_person, account: ixport_account)).rows
11
+ @counts = { add: 0, update: 0, refused: 0 }.merge(rows.group_by(&:outcome).transform_values(&:size))
12
+ @update_rows = rows.select { |row| row.outcome == :update }
13
+ @refused_rows = rows.select { |row| row.outcome == :refused }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ixport
4
+ class RunsController < ApplicationController
5
+ before_action :find_import
6
+
7
+ def create
8
+ @import.run!(@import.importer_class.new(person: ixport_person, account: ixport_account))
9
+ redirect_to import_path(@import)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ixport
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Ixport
6
+ class Import < ApplicationRecord
7
+ self.table_name = "ixport_imports"
8
+
9
+ belongs_to :person, polymorphic: true
10
+ belongs_to :account, polymorphic: true, optional: true
11
+
12
+ has_one_attached :file
13
+ has_many :refusals, -> { order(:row_number) }, class_name: "Ixport::ImportRefusal", dependent: :destroy
14
+
15
+ validate :file_has_header_row, on: :create
16
+ validate :file_within_inline_row_limit, on: :create
17
+
18
+ def run!(importer)
19
+ with_lock do
20
+ walk_and_save(importer) unless finished?
21
+ end
22
+ end
23
+
24
+ def finished?
25
+ status == "finished"
26
+ end
27
+
28
+ def importer_class
29
+ Ixport.registration(key).importer.constantize
30
+ end
31
+
32
+ def headers
33
+ CSV.parse_line(file.download) || []
34
+ end
35
+
36
+ private
37
+
38
+ def walk_and_save(importer)
39
+ rows = RowWalk.new(self, importer).rows
40
+ rows.each do |row|
41
+ if row.outcome == :refused
42
+ refusals.create!(row_number: row.number, reason: row.reason)
43
+ else
44
+ row.record.save!
45
+ end
46
+ end
47
+ update!(status: "finished", added_count: rows.count { |row| row.outcome == :add }, updated_count: rows.count { |row| row.outcome == :update })
48
+ end
49
+
50
+ def file_has_header_row
51
+ errors.add(:file, "This file has no header row.") if CSV.parse_line(incoming_file_contents).blank?
52
+ end
53
+
54
+ def file_within_inline_row_limit
55
+ limit = Ixport.configuration.inline_row_limit
56
+ rows = [ CSV.parse(incoming_file_contents).size - 1, 0 ].max
57
+ errors.add(:file, "This file has #{rows} rows, and the most an import can have is #{limit}.") if rows > limit
58
+ end
59
+
60
+ def incoming_file_contents
61
+ attachable = attachment_changes["file"]&.attachable
62
+ io = attachable.is_a?(Hash) ? attachable[:io] : attachable
63
+ return "" unless io
64
+
65
+ io.read.tap { io.rewind }.to_s
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ixport
4
+ class ImportRefusal < ApplicationRecord
5
+ self.table_name = "ixport_import_refusals"
6
+
7
+ belongs_to :import
8
+ end
9
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Ixport
6
+ class RowWalk
7
+ Row = Struct.new(:number, :record, :outcome, :reason, keyword_init: true)
8
+
9
+ def initialize(import, importer)
10
+ @import = import
11
+ @importer = importer
12
+ end
13
+
14
+ def rows
15
+ @rows ||= data_rows.each_with_index.map { |cells, index| row_for(cells, index + 2) }
16
+ end
17
+
18
+ private
19
+
20
+ def row_for(cells, number)
21
+ values = values_of(cells)
22
+ existing = existing_records[values[match_field]]
23
+ record = existing ? existing.tap { |found| found.assign_attributes(values) } : @importer.records.new(values)
24
+ return Row.new(number: number, record: record, outcome: :refused, reason: record.errors.full_messages.to_sentence) unless record.valid?
25
+
26
+ Row.new(number: number, record: record, outcome: existing ? :update : :add)
27
+ end
28
+
29
+ def data_rows
30
+ @data_rows ||= CSV.parse(@import.file.download).drop(1)
31
+ end
32
+
33
+ def values_of(cells)
34
+ @import.mapping.to_h { |column, field| [ field.to_sym, cells[column.to_i] ] }
35
+ end
36
+
37
+ def match_field
38
+ @importer.class.match_field
39
+ end
40
+
41
+ def existing_records
42
+ @existing_records ||= begin
43
+ match_values = data_rows.map { |cells| values_of(cells)[match_field] }.compact_blank
44
+ @importer.records.where(match_field => match_values).index_by { |record| record.public_send(match_field).to_s }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,10 @@
1
+ <%= ui_page(max_width: :md) do %>
2
+ <h1>Imports</h1>
3
+ <% if @registrations.empty? %>
4
+ <%= ui_alert(message: "No imports are set up yet.") %>
5
+ <% else %>
6
+ <% @registrations.each do |registration| %>
7
+ <div data-import><%= link_to registration.title, new_import_path(key: registration.key) %></div>
8
+ <% end %>
9
+ <% end %>
10
+ <% end %>
@@ -0,0 +1,11 @@
1
+ <%= ui_form_page(title: @registration.title, back_url: root_path) %>
2
+ <%= ui_page(max_width: :md) do %>
3
+ <% if @error.present? %>
4
+ <%= ui_alert(type: :error, message: tag.span(@error, data: { upload_error: true })) %>
5
+ <% end %>
6
+ <%= ui_form(action: imports_path, multipart: true) do %>
7
+ <%= hidden_field_tag "key", @registration.key %>
8
+ <%= ui_file_upload(name: "file", label: "CSV file", accept: ".csv") %>
9
+ <%= ui_button(label: "Upload") %>
10
+ <% end %>
11
+ <% end %>
@@ -0,0 +1,20 @@
1
+ <%= ui_show_page(title: "Import finished", back_url: root_path) %>
2
+ <%= ui_page(max_width: :md) do %>
3
+ <%= ui_page_header(title: "Import finished") %>
4
+ <%= ui_grid(cols: { default: 1, sm: 2 }) do %>
5
+ <%= ui_stat_card(label: "Added", value: tag.span(@import.added_count, data: { result_count: "added" })) %>
6
+ <%= ui_stat_card(label: "Updated", value: tag.span(@import.updated_count, data: { result_count: "updated" })) %>
7
+ <% end %>
8
+ <% if @import.refusals.any? %>
9
+ <%= ui_section(title: "Refused") do %>
10
+ <ul>
11
+ <% @import.refusals.each do |refusal| %>
12
+ <%= tag.li(data: { refused_row: true }) do %>
13
+ Row <%= tag.span(refusal.row_number, data: { row_number: true }) %>
14
+ <%= tag.span(refusal.reason, data: { reason: true }) %>
15
+ <% end %>
16
+ <% end %>
17
+ </ul>
18
+ <% end %>
19
+ <% end %>
20
+ <% end %>
@@ -0,0 +1,12 @@
1
+ <%= ui_form_page(title: "Match columns", back_url: root_path) %>
2
+ <%= ui_page(max_width: :md) do %>
3
+ <%= ui_form(action: import_mapping_path(@import), method: :patch) do %>
4
+ <% @import.headers.each_with_index do |header, index| %>
5
+ <%= tag.div(data: { column: true }) do %>
6
+ <%= tag.span(header, data: { column_name: true }) %>
7
+ <%= ui_select(name: "mapping[#{index}]", options: @fields.map { |field| [field.label, field.key] }, include_blank: "Skip") %>
8
+ <% end %>
9
+ <% end %>
10
+ <%= ui_button(label: "Save mapping") %>
11
+ <% end %>
12
+ <% end %>
@@ -0,0 +1,35 @@
1
+ <%= ui_show_page(title: "Preview", back_url: import_mapping_path(@import)) %>
2
+ <%= ui_page(max_width: :md) do %>
3
+ <%= ui_page_header(title: "Preview") %>
4
+ <%= ui_grid(cols: { default: 1, sm: 3 }) do %>
5
+ <%= ui_stat_card(label: "Will add", value: tag.span(@counts[:add], data: { count: "add" })) %>
6
+ <%= ui_stat_card(label: "Will update", value: tag.span(@counts[:update], data: { count: "update" })) %>
7
+ <%= ui_stat_card(label: "Will be refused", value: tag.span(@counts[:refused], data: { count: "refused" })) %>
8
+ <% end %>
9
+ <% if @update_rows.any? %>
10
+ <%= ui_section(title: "Will update") do %>
11
+ <ul>
12
+ <% @update_rows.each do |row| %>
13
+ <%= tag.li(data: { update_row: true }) do %>
14
+ Row <%= tag.span(row.number, data: { row_number: true }) %>
15
+ <% end %>
16
+ <% end %>
17
+ </ul>
18
+ <% end %>
19
+ <% end %>
20
+ <% if @refused_rows.any? %>
21
+ <%= ui_section(title: "Will be refused") do %>
22
+ <ul>
23
+ <% @refused_rows.each do |row| %>
24
+ <%= tag.li(data: { refused_row: true }) do %>
25
+ Row <%= tag.span(row.number, data: { row_number: true }) %>
26
+ <%= tag.span(row.reason, data: { reason: true }) %>
27
+ <% end %>
28
+ <% end %>
29
+ </ul>
30
+ <% end %>
31
+ <% end %>
32
+ <%= ui_form(action: import_run_path(@import)) do %>
33
+ <%= ui_button(label: "Run import") %>
34
+ <% end %>
35
+ <% end %>
data/config/routes.rb ADDED
@@ -0,0 +1,8 @@
1
+ Ixport::Engine.routes.draw do
2
+ root "imports#index"
3
+ resources :imports, only: %i[new create show] do
4
+ resource :mapping, only: %i[show update]
5
+ resource :preview, only: :show
6
+ resource :run, only: :create
7
+ end
8
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Ixport
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Installs Ixport: copies the migrations for its tables."
14
+
15
+ def copy_migrations
16
+ migration_template "create_ixport_imports.rb.erb", "db/migrate/create_ixport_imports.rb"
17
+ migration_template "create_ixport_import_refusals.rb.erb", "db/migrate/create_ixport_import_refusals.rb"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,10 @@
1
+ class CreateIxportImportRefusals < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :ixport_import_refusals do |t|
4
+ t.references :import, null: false, foreign_key: { to_table: :ixport_imports }
5
+ t.integer :row_number, null: false
6
+ t.text :reason, null: false
7
+ t.timestamps
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,14 @@
1
+ class CreateIxportImports < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :ixport_imports do |t|
4
+ t.string :key, null: false
5
+ t.references :person, polymorphic: true, null: false
6
+ t.references :account, polymorphic: true, null: true
7
+ t.json :mapping
8
+ t.string :status, null: false, default: "pending"
9
+ t.integer :added_count, null: false, default: 0
10
+ t.integer :updated_count, null: false, default: 0
11
+ t.timestamps
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ixport
4
+ class Configuration
5
+ attr_accessor :authentication_method, :current_person_method, :current_account_method, :layout, :inline_row_limit
6
+
7
+ def initialize
8
+ @authentication_method = :authenticate_user!
9
+ @current_person_method = :current_user
10
+ @current_account_method = :current_account
11
+ @layout = "application"
12
+ @inline_row_limit = 1_000
13
+ end
14
+ end
15
+
16
+ def self.configuration
17
+ @configuration ||= Configuration.new
18
+ end
19
+
20
+ def self.configure
21
+ yield(configuration)
22
+ end
23
+
24
+ def self.reset_configuration!
25
+ @configuration = Configuration.new
26
+ end
27
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ixport
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Ixport
6
+
7
+ config.after_initialize { Ixport.check_registrations! }
8
+ end
9
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ixport
4
+ class Importer
5
+ Field = Struct.new(:key, :label, keyword_init: true)
6
+
7
+ attr_reader :person, :account
8
+
9
+ def initialize(person:, account:)
10
+ @person = person
11
+ @account = account
12
+ end
13
+
14
+ def self.field(key, label:)
15
+ fields << Field.new(key: key.to_sym, label: label)
16
+ end
17
+
18
+ def self.fields
19
+ @fields ||= []
20
+ end
21
+
22
+ def self.match_on(key)
23
+ @match_field = key.to_sym
24
+ end
25
+
26
+ def self.match_field
27
+ @match_field
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ixport
4
+ Registration = Struct.new(:key, :title, :importer, keyword_init: true)
5
+
6
+ class MissingImporter < StandardError; end
7
+
8
+ def self.register_import(key, title:, importer:)
9
+ registrations[key.to_sym] = Registration.new(key: key.to_sym, title: title, importer: importer.to_s)
10
+ end
11
+
12
+ def self.registration(key)
13
+ registrations.fetch(key.to_sym)
14
+ end
15
+
16
+ def self.registrations
17
+ @registrations ||= {}
18
+ end
19
+
20
+ def self.check_registrations!
21
+ registrations.each_value do |registration|
22
+ next if registration.importer.safe_constantize
23
+
24
+ raise MissingImporter, "Ixport import #{registration.key} names the importer #{registration.importer}, which does not exist."
25
+ end
26
+ end
27
+
28
+ def self.reset_registrations!
29
+ @registrations = {}
30
+ end
31
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ixport
4
+ VERSION = "0.1.0"
5
+ end
data/lib/ixport.rb ADDED
@@ -0,0 +1,9 @@
1
+ require "keystone_ui"
2
+ require "ixport/version"
3
+ require "ixport/configuration"
4
+ require "ixport/registrations"
5
+ require "ixport/importer"
6
+ require "ixport/engine"
7
+
8
+ module Ixport
9
+ end
@@ -0,0 +1,104 @@
1
+ ---
2
+ name: ixport-develop
3
+ description: Use PROACTIVELY for setting up CSV imports in a Rails app that has ixport — registering a mapped import, writing its importer class, declaring the fields a person maps a CSV's columns to, choosing the field rows are matched on, scoping the records an import may add or update to the signed-in person or account, linking to an import's upload page, explaining the upload, column-matching, preview and result pages and their messages, explaining what running an import saves and refuses, and fixing a boot that fails with Ixport::MissingImporter — MUST BE USED instead of hand-writing a CSV upload controller, a column-mapping form, a row-matching loop, an import preview or a loop that saves imported rows.
4
+ tools: Bash, Read, Write, Edit, Grep
5
+ scope: CSV import and export — a mounted engine where a signed-in person sees the mapped imports the host app registers, each backed by an importer class the host writes
6
+ ---
7
+
8
+ This local adds mapped imports to a Rails app that already has ixport installed by following the steps below exactly. It writes one importer class and one registration line per import, and it invents no method, option, route or file that is not listed here.
9
+
10
+ ## What ixport is
11
+
12
+ ixport is a Rails engine that lets a signed-in person import a host app's records from CSV files. The app registers each kind of CSV a person can import, called a mapped import, and writes an importer class that names the fields the CSV's columns map to, the one field rows are matched on, and the records a row may add to or update. A person picks an import, uploads a CSV file for it, matches each of the file's columns to one of the importer's fields, sees a preview of how many rows will add a record, update one or be refused, runs the import to save those rows, and then sees how many rows were added and updated and each refused row. Fire this local when a developer wants people to load one of the app's records from a spreadsheet, wants to link to an import's upload page, asks what the upload, column-matching, preview or result pages do, asks what running an import saves, or when the app fails to boot with `Ixport::MissingImporter`.
13
+
14
+ ## Interface
15
+
16
+ - `Ixport.register_import` — `Ixport.register_import(key, title:, importer:)` registers one mapped import under a key, with the title a person sees and the name of its importer class.
17
+ - `Ixport::Importer` — the base class every importer inherits from.
18
+ - `field` — `field key, label:` is called in an importer's class body and declares one field a CSV column can be mapped to, with the label a person sees.
19
+ - `match_on` — `match_on key` is called in an importer's class body and names the one declared field a row is matched to an existing record on.
20
+ - `records` — an instance method every importer defines, returning the Active Record relation a row may update a record from and adds a new record to.
21
+ - `person` — returns the person the import belongs to, the one who uploaded the file, for use inside `records`.
22
+ - `account` — returns the account the import belongs to, the one current when the file was uploaded, for use inside `records`, and is `nil` when the app has no current account.
23
+ - `Ixport::MissingImporter` — the error raised when the app boots if a registered import names an importer class that does not exist.
24
+ - `GET /` — the engine's root page, at the path the engine is mounted under, which lists every registered import by its title, each linking to that import's upload page.
25
+ - `GET /imports/new` — the upload page for one import, given the import's registered key as the `key` query parameter, which shows the import's title, a CSV file field and an Upload button.
26
+ - `POST /imports` — takes the `key` and `file` parameters, stores the uploaded file as an import belonging to the signed-in person and the current account, and redirects to that import's column-matching page.
27
+ - `GET /imports/:import_id/mapping` — the column-matching page, titled "Match columns", which lists each column header from the file's first row beside a select of the importer's field labels and a "Skip" choice.
28
+ - `PATCH /imports/:import_id/mapping` — saves which field each column maps to and redirects to the import's preview page.
29
+ - `GET /imports/:import_id/preview` — the preview page, titled "Preview", which shows how many rows will add a record, update one or be refused, lists each updating row and each refused row by its row number, saves nothing, and offers a "Run import" button, and which redirects to the import's result page once the import has run.
30
+ - `POST /imports/:import_id/run` — saves every row that adds or updates a record, records every refused row with its reason, marks the import finished, and redirects to the import's result page, and does nothing but redirect when the import has already run.
31
+ - `GET /imports/:id` — the result page, titled "Import finished", which shows how many rows the run added and updated and lists each refused row by its row number with its reason, and which redirects to the import's preview page when the import has not run.
32
+
33
+ ## How to use it
34
+
35
+ 1. Check that ixport is installed: `bin/rails routes` lists a path served by ixport. If it does not, stop and hand the setup to the `ixport-install` local before going on.
36
+ 2. Ask the developer which of the app's records the import loads, such as products or customers. Do not pick one.
37
+ 3. Ask the developer which attributes of that record a person may import, and the label a person should see for each, such as "SKU" for `sku` or "Price" for `price_cents`. Do not decide which attributes are importable.
38
+ 4. Ask the developer which of those fields identifies an existing record, such as the SKU or the email. Do not pick the match field.
39
+ 5. Ask the developer which records a person may add to or update, such as the current account's products or the signed-in person's contacts. Do not pick the scope. An import that reaches every row of the table lets one account's file update another account's records.
40
+ 6. Write the importer class in `app/importers/`, named after the record, inheriting from `Ixport::Importer`:
41
+ ```ruby
42
+ class ProductImporter < Ixport::Importer
43
+ field :sku, label: "SKU"
44
+ field :price_cents, label: "Price"
45
+ match_on :sku
46
+
47
+ def records
48
+ account.products
49
+ end
50
+ end
51
+ ```
52
+ - Call `field` once per attribute from step 3, in the order a person should see them. The key is the record's attribute name as a symbol, and `label:` is required.
53
+ - Each field key must be an attribute the record can be assigned, because a row's mapped values are assigned to the record by those keys.
54
+ - The labels are the choices on the column-matching page, in the order the fields are declared.
55
+ - Call `match_on` once, with the key of a field declared with `field`, from step 4.
56
+ - Define `records` to return a relation of the record from step 5, built from `person` or `account`. A new record is built from this relation, so a record added through `account.products` already belongs to that account.
57
+ - `account` is `nil` when the app has no current account, so a `records` that calls a method on `account` fails in that case. Ask the developer whether that can happen in this app.
58
+ - Never define `initialize` in an importer. ixport builds the importer with the import's person and account.
59
+ 7. Ask the developer what key and title the import gets. The key is a short symbol that identifies the import, such as `:products`. The title is what a person sees on the imports page and as the heading of the upload page, such as "Products".
60
+ 8. Add the registration to the end of `config/initializers/ixport.rb`, and create the file if it does not exist:
61
+ ```ruby
62
+ Ixport.register_import :products, title: "Products", importer: "ProductImporter"
63
+ ```
64
+ Pass `importer:` the class name as a string, never the class itself, so the initializer does not load the class before the app's code is ready.
65
+ 9. Start the app. If it fails to boot with `Ixport::MissingImporter`, the message names the import key and the class name that does not exist. Fix the class name in the registration, or the class name or file name of the importer, so the two match.
66
+ 10. Visit the path the engine is mounted under while signed in. The page shows the heading "Imports" and lists each registered import by its title, in the order the imports were registered. With no import registered it shows "No imports are set up yet."
67
+ 11. When the developer wants a link to one import's upload page from the app's own pages, ask where the link goes. Build its URL with the engine's route helper and the import's registered key:
68
+ ```erb
69
+ <%= link_to "Import products", ixport.new_import_path(key: :products) %>
70
+ ```
71
+ The key must be one that is registered. An unregistered key gets a not found response.
72
+ 12. On the upload page a person chooses a CSV file and presses Upload. The upload is refused, and the upload page is shown again with status 422 and one of these messages, when:
73
+ - the file has no header row: "This file has no header row."
74
+ - the file has more data rows than the inline row limit, not counting the header row: "This file has N rows, and the most an import can have is L." The limit is set by `ixport-install`.
75
+ 13. After an upload is accepted, the person is on the "Match columns" page. Each column header from the file's first row is listed with a select of the importer's field labels. "Skip" leaves that column unmapped. Pressing "Save mapping" saves the choices and takes the person to the "Preview" page.
76
+ 14. The preview decides each data row's outcome this way:
77
+ - The row's value for the match field is looked up in `records`, with one query for the whole file.
78
+ - A row matches a record only when its value is exactly the same text as the record's match attribute written as text.
79
+ - A row that matches a record will update it with the row's mapped values.
80
+ - A row that matches nothing, a row whose match value is blank, and every row of a file with no column mapped to the match field will add a new record built from `records`.
81
+ - A row whose added or updated record is not valid will be refused, with the record's own validation messages as the reason.
82
+ 15. The preview page shows three counts, "Will add", "Will update" and "Will be refused". Under them it lists each row that will update a record under "Will update", and each refused row with its reason under "Will be refused", each by its row number in the file, where the header row is row 1. The refusal reasons are the record's validation messages, so tell the developer that the record's validations decide which rows are refused and what a person reads.
83
+ 16. Viewing the preview adds and changes no record. Its back link returns the person to the "Match columns" page to change the mapping.
84
+ 17. Pressing "Run import" on the preview page runs the import within the same request, with no background job:
85
+ - Every row's outcome is decided again by the rules in step 14, against the records as they are at that moment, so the result can differ from the preview when records changed in between.
86
+ - Every row that adds or updates a record is saved with `save!`, so the record's callbacks run as for any other save.
87
+ - Every refused row is recorded with its row number and reason, and no record is saved for it.
88
+ - The whole run happens in one database transaction. When any save raises, such as on a database constraint the record's validations do not cover, nothing from the run is saved and the import stays unrun. Tell the developer to cover every database constraint on the record with a validation, so a bad row is refused rather than stopping the whole run.
89
+ - An import runs once. A second run request for the same import, including one sent at the same time as the first, adds and changes nothing and redirects to the result page.
90
+ 18. After the run, the person is on the "Import finished" page. It shows two counts, "Added" and "Updated", and, when any row was refused, lists each refused row under "Refused" by its row number with its reason. Returning to this page later shows the same counts and refused rows. Visiting this page for an import that has not run redirects to its preview page. Once an import has run, its preview page redirects to this page, so it offers no "Run import" button.
91
+ 19. An import's column-matching, preview, run and result pages are visible only to the person who uploaded it, in the account it was uploaded in. Anyone else, and the same person in another account, gets a not found response.
92
+
93
+ ## Conventions
94
+
95
+ - Every registration names an importer class that exists. The app refuses to boot otherwise, so never register an import before its importer class is written.
96
+ - Each key is registered once. Registering the same key a second time replaces the first registration.
97
+ - Every importer declares `match_on` and defines `records`.
98
+ - `records` is always scoped to the `person` or `account` the developer chose, never left to the whole table without the developer saying so.
99
+ - An importer inherits from `Ixport::Importer` directly and declares all of its own fields and its own match field. Neither is inherited by a class that inherits from another importer.
100
+ - When a record's attribute is renamed or removed, update the matching `field` line and any `match_on` that names it in the same change.
101
+ - When an importer class is renamed, update its `importer:` string in `config/initializers/ixport.rb` in the same change.
102
+ - When an import's key is changed, update every link to its upload page in the same change.
103
+ - Never write an upload form, a column-matching form, a preview page, a run action, a result page or a controller for them in the app. The engine serves all of them.
104
+ - Installing ixport, mounting the engine, the tables an import and its refused rows are stored in, file storage, sign-in, how the current person and account are found, and the inline row limit are out of scope for this local and belong to `ixport-install`.
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: ixport-info
3
+ description: Use to learn what ixport offers — CSV import through a mounted Rails engine, the mapped imports a host app registers, the importer classes behind them, uploading a file, matching its columns, previewing which rows will add, update or be refused, running the import, its result page, and the vocabulary the install and develop locals assume.
4
+ tools: Read
5
+ scope: CSV import and export — a mounted engine where a signed-in person sees the mapped imports the host app registers, each backed by an importer class the host writes
6
+ ---
7
+
8
+ This local explains what ixport is and routes you to the local that does the work. It makes no changes and gives no steps.
9
+
10
+ ## What ixport is
11
+
12
+ ixport is a Rails engine for loading a host app's records from CSV files. The host app mounts it at a path of its choosing, and a signed-in person who visits that path sees the list of imports the host has set up, each shown by its title.
13
+
14
+ A person picks an import, uploads a CSV file, matches each column of the file to one of the columns the import reads or skips it, and then sees a preview. The preview counts how many rows will add a record, how many will update one, and how many will be refused, and lists the rows that will update and the rows that will be refused with the reason for each. Viewing the preview saves nothing in the host app, and a Back link returns to the mapping so the person can change it.
15
+
16
+ From the preview the person runs the import. The run saves every added and updated record in the host app, records each refused row with its reason, and sends the person to the import's result page, which shows how many rows were added and updated and lists each refused row. The run happens inside the request, an import runs once, and asking to run it again changes nothing. Opening the preview of a finished import sends the person to its result page instead, and opening the result page of an import that has not run sends the person to its preview.
17
+
18
+ ixport refuses a file with no header row, and a file with more rows than the host's row limit, on the upload page. It has no export yet.
19
+
20
+ The engine owns the screens, the access check, and its own tables of import records and refused rows, with the uploaded file stored through Active Storage. The host owns what each import means: which columns it reads, which column identifies an existing record, and which of the app's records a row is checked against and saved into. Reach for ixport when a Rails app needs people to load data from spreadsheets, rather than writing a one-off upload page per record type.
21
+
22
+ The engine renders its pages with Keystone UI inside a layout the host chooses, and it runs its access check through a method the host already has.
23
+
24
+ ## Interface
25
+
26
+ ixport declares nothing for this local to document. Its surface is split between the other two locals:
27
+
28
+ - **ixport-install** owns hooking ixport into a host app: adding the gem, running its install generator, mounting the engine, and the configuration that names the host's sign-in check, current person, current account, layout and row limit.
29
+ - **ixport-develop** owns building against ixport: registering a mapped import, writing the importer class behind it, the error raised when a registration names an importer that does not exist, and the engine's pages for listing imports, uploading a file, matching its columns, previewing the result, running the import and showing its result.
30
+
31
+ ## How to use it
32
+
33
+ - The app does not have ixport yet, or you are changing how ixport reaches the app's sign-in, people, accounts, layout or row limit: use **ixport-install**.
34
+ - ixport is already installed and you are adding or changing an import, changing how its rows are matched, checked or saved, linking to the engine's pages, or the app fails to boot naming a missing importer: use **ixport-develop**.
35
+
36
+ ## Conventions
37
+
38
+ - **Mapped import** — one kind of import the host registers, made of a key, a title shown to people, and the name of its importer class.
39
+ - **Key** — the short name a mapped import is registered and looked up by, held as a symbol.
40
+ - **Title** — the words a person sees for the import on the engine's page.
41
+ - **Importer** — a class the host app writes that declares the columns the import reads, each with a key and a label, names the column that identifies an existing record, and supplies the set of the app's records the rows are checked against and saved into.
42
+ - **Match column** — the one declared column whose value decides whether a row is an existing record or a new one.
43
+ - **Import record** — one upload by one person, stored by the engine with the key of its mapped import, the uploaded file, the mapping once it is saved, and the added and updated counts once it has run.
44
+ - **Headers** — the first row of the uploaded file, read as the names of its columns.
45
+ - **Mapping** — which of the importer's columns each header in the file fills, stored by the header's position in the row. A header left on "Skip" is not stored.
46
+ - **Row limit** — the most data rows an uploaded file may have, not counting the header row. It defaults to 1,000 and the host can change it.
47
+ - **Row number** — a row's line in the file, counting the header as row 1, so the first data row is row 2.
48
+ - **Add** — a row whose match column value is found in no existing record, so it creates one.
49
+ - **Update** — a row whose match column value is found in an existing record, so it changes that record.
50
+ - **Refused** — a row whose record, new or changed, fails the host model's own validations. Its reason is that model's error messages.
51
+ - **Preview** — the page that shows the add, update and refused counts and lists the update and refused rows by row number. It builds each row's record in memory and saves none of them.
52
+ - **Run** — the step that saves the added and updated records and stores each refused row's number and reason. A run that raises an error saves nothing, and an import that has finished is never run a second time.
53
+ - **Finished** — the state of an import record once it has run.
54
+ - **Result page** — the import's own page, showing the added and updated counts and each refused row as they were stored by the run, so it shows the same thing each time it is opened.
55
+ - **Person** — whoever is signed in and running the import. ixport reaches them through a method the host names, so the host's user model needs no particular name.
56
+ - **Account** — the optional owner the person is working inside, such as a team or company, reached the same way. An app with no accounts runs without one.
57
+ - **Ownership** — an import record belongs to the person and account that uploaded it, and anyone else who opens it, including the same person from another account, is answered not found.
58
+ - **Boot check** — when the app starts, ixport checks that every registered importer class exists and refuses to boot when one does not, so a misspelled class name fails at startup rather than when someone runs the import.
59
+ - An import is registered by the host app, never discovered: an importer class that is not registered does not appear on the page.
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: ixport-install
3
+ description: Use to hook ixport into a project — adding the gem, checking Active Storage is set up, running the install generator and its migrations, mounting the engine, and configuring the sign-in check, the current person method, the current account method, the layout and the inline row limit.
4
+ tools: Bash, Read, Edit
5
+ scope: CSV import and export — a mounted engine where a signed-in person sees the mapped imports the host app registers, each backed by an importer class the host writes
6
+ ---
7
+
8
+ This local hooks ixport into a Rails app by following the steps below exactly. It invents no step, setting or file that is not listed here.
9
+
10
+ ## What ixport is
11
+
12
+ ixport is a Rails engine that lets a signed-in person upload a CSV file, match its columns to a host app's fields, preview which rows will add, update or be refused, and then run the import to save those records into the host app. Hook it in when a Rails app needs people to load data from spreadsheets.
13
+
14
+ ## Interface
15
+
16
+ - `gem "ixport"` — the Gemfile line that adds ixport to the app. It also brings in Keystone UI, which draws ixport's pages.
17
+ - `bin/rails generate ixport:install` — copies two migrations into `db/migrate/`, one that creates the `ixport_imports` table and one that creates the `ixport_import_refusals` table.
18
+ - `mount Ixport::Engine` — the line in `config/routes.rb` that serves ixport's pages under a path the app chooses.
19
+ - `Ixport.configure` — takes a block that receives the configuration object, where the app sets any of the five settings below.
20
+ - `config.authentication_method` — the name of the controller method run before every ixport page, as a symbol. It defaults to `:authenticate_user!`.
21
+ - `config.current_person_method` — the name of the controller method that returns the signed-in person running imports, as a symbol. It defaults to `:current_user`.
22
+ - `config.current_account_method` — the name of the controller method that returns the account the person is working in, as a symbol. It defaults to `:current_account`.
23
+ - `config.layout` — the name of the host layout ixport's pages are drawn inside, as a string. It defaults to `"application"`.
24
+ - `config.inline_row_limit` — the most data rows an uploaded file may have, not counting its header row, as an integer. A file with more is refused on the upload page with a message giving the limit and the file's row count. It defaults to `1_000`.
25
+
26
+ ## How to use it
27
+
28
+ 1. Check that the app has Keystone UI set up: the layout ixport will use has `<%= keystone_theme_attributes %>` in its `<html>` tag. If it does not, stop and hand Keystone UI's setup to the `keystone_ui-install` local before going on, because ixport's pages are drawn with Keystone UI components and its styles.
29
+ 2. Check that the app has Active Storage set up, because ixport keeps each uploaded file as an Active Storage attachment. All three must hold:
30
+ - `config/application.rb` loads Active Storage, either through `require "rails/all"` or through `require "active_storage/engine"`.
31
+ - `db/schema.rb` has the `active_storage_blobs`, `active_storage_attachments` and `active_storage_variant_records` tables.
32
+ - Each environment file in `config/environments/` sets `config.active_storage.service`, and `config/storage.yml` defines that service.
33
+
34
+ If any is missing, tell the developer ixport cannot accept uploads without Active Storage and ask before setting it up. Where the tables are missing, setting it up means running `bin/rails active_storage:install` and then `bin/rails db:migrate`. Ask the developer which storage service each environment should use rather than picking one.
35
+ 3. Add the gem to the app's `Gemfile`:
36
+ ```ruby
37
+ gem "ixport"
38
+ ```
39
+ Then run `bundle install`.
40
+ 4. Run the install generator:
41
+ ```bash
42
+ bin/rails generate ixport:install
43
+ ```
44
+ It writes two files and touches no other:
45
+ - `db/migrate/<timestamp>_create_ixport_imports.rb` creates the `ixport_imports` table with a required `key` string, a required polymorphic `person` reference, an optional polymorphic `account` reference, a `mapping` JSON column, a required `status` string defaulting to `"pending"`, required `added_count` and `updated_count` integers defaulting to `0`, and timestamps.
46
+ - `db/migrate/<timestamp>_create_ixport_import_refusals.rb` creates the `ixport_import_refusals` table with a required `import` reference with a foreign key to `ixport_imports`, a required `row_number` integer, a required `reason` text, and timestamps.
47
+ 5. Run the migrations:
48
+ ```bash
49
+ bin/rails db:migrate
50
+ ```
51
+ 6. Ask the developer which path ixport's pages should live under. There is no default path. Add the mount inside `Rails.application.routes.draw` in `config/routes.rb`, using their answer:
52
+ ```ruby
53
+ mount Ixport::Engine => "/data_imports"
54
+ ```
55
+ If the app's routes put signed-in pages inside a block such as `authenticate :user do`, ask the developer whether the mount goes inside it.
56
+ 7. Read the app's `ApplicationController` and the controller methods it gets from its sign-in setup. ixport's controllers inherit from the app's `ApplicationController`, so every method named in the next step must be callable from it.
57
+ 8. Compare the app's method and layout names with the defaults in the Interface section. Every default matches a Jumpstart app, so an app that uses all of the defaults needs no configuration. Where any name differs, or a method does not exist, ask the developer which method or layout to use rather than picking one:
58
+ - The sign-in check: the method that sends a person who is not signed in to sign in, such as `:authenticate_user!` or `:require_login`.
59
+ - The current person: the method returning the signed-in person, such as `:current_user` or `:current_member`. An import belongs to this person, and another person who opens it gets a not found response.
60
+ - The current account: the method returning the team or company the person works inside, such as `:current_account` or `:current_team`. An import belongs to this account too, and the same person who opens it from another account gets a not found response. Ask whether the app has accounts at all. ixport calls the named method on every page, so an app with no accounts still needs a method of that name callable from `ApplicationController`, and it returns `nil`. Ask the developer before adding one.
61
+ - The layout: the file in `app/views/layouts/` that ixport's pages should be drawn inside.
62
+ - The inline row limit: ask only if the developer wants a limit other than 1,000 rows.
63
+ 9. Write only the settings that differ from the defaults into a new initializer, `config/initializers/ixport.rb`:
64
+ ```ruby
65
+ Ixport.configure do |config|
66
+ config.authentication_method = :require_login
67
+ config.current_person_method = :current_member
68
+ config.current_account_method = :current_team
69
+ config.layout = "back_office"
70
+ config.inline_row_limit = 5_000
71
+ end
72
+ ```
73
+ The values above are examples, and each line is written only when the developer's answer differs from the default.
74
+ 10. Start the app and visit the mounted path while signed in. The page shows the heading "Imports" and the message "No imports are set up yet." Visiting it while signed out sends the browser through the app's own sign-in check.
75
+
76
+ ## Conventions
77
+
78
+ - After installing, check that `bin/rails db:migrate:status` lists both the create-ixport-imports and the create-ixport-import-refusals migrations as up, that `bin/rails routes` lists the mount path, and that the page at that path loads as described in step 10.
79
+ - Run the install generator once. A second run against unchanged copies reports each migration as identical and adds nothing. If a copied migration differs from the one the generator would write, the run stops with an error naming that migration, and copies nothing after it. Do not delete or edit a copied migration once it has been run.
80
+ - When the app renames its sign-in method, its current person or account method, or the layout ixport uses, update the matching line in `config/initializers/ixport.rb` in the same change.
81
+ - A setting given the same value as its default is left out of the initializer. Remove a line when its value goes back to the default.
82
+ - Setting up the imports that appear on the page, and the upload, mapping, preview, run and result pages they lead to, is out of scope for this local and belongs to `ixport-develop`.
@@ -0,0 +1,55 @@
1
+ scope: CSV import and export — a mounted engine where a signed-in person sees the mapped imports the host app registers, each backed by an importer class the host writes
2
+
3
+ install:
4
+ - gem "ixport"
5
+ - bin/rails generate ixport:install
6
+ - mount Ixport::Engine
7
+ - Ixport.configure
8
+ - config.authentication_method
9
+ - config.current_person_method
10
+ - config.current_account_method
11
+ - config.layout
12
+ - config.inline_row_limit
13
+
14
+ develop:
15
+ - Ixport.register_import
16
+ - Ixport::Importer
17
+ - field
18
+ - match_on
19
+ - records
20
+ - person
21
+ - account
22
+ - Ixport::MissingImporter
23
+ - GET /
24
+ - GET /imports/new
25
+ - POST /imports
26
+ - GET /imports/:import_id/mapping
27
+ - PATCH /imports/:import_id/mapping
28
+ - GET /imports/:import_id/preview
29
+ - POST /imports/:import_id/run
30
+ - GET /imports/:id
31
+
32
+ sources:
33
+ - lib/ixport.rb
34
+ - lib/ixport/engine.rb
35
+ - lib/ixport/configuration.rb
36
+ - lib/ixport/registrations.rb
37
+ - lib/ixport/importer.rb
38
+ - lib/generators/ixport/install/install_generator.rb
39
+ - lib/generators/ixport/install/templates/create_ixport_imports.rb.erb
40
+ - config/routes.rb
41
+ - app/controllers/ixport/application_controller.rb
42
+ - app/controllers/ixport/imports_controller.rb
43
+ - app/views/ixport/imports/index.html.erb
44
+ - app/models/ixport/application_record.rb
45
+ - app/models/ixport/import.rb
46
+ - app/controllers/ixport/mappings_controller.rb
47
+ - app/views/ixport/imports/new.html.erb
48
+ - app/views/ixport/mappings/show.html.erb
49
+ - app/models/ixport/row_walk.rb
50
+ - app/controllers/ixport/previews_controller.rb
51
+ - app/views/ixport/previews/show.html.erb
52
+ - app/models/ixport/import_refusal.rb
53
+ - app/controllers/ixport/runs_controller.rb
54
+ - app/views/ixport/imports/show.html.erb
55
+ - lib/generators/ixport/install/templates/create_ixport_import_refusals.rb.erb
metadata ADDED
@@ -0,0 +1,148 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ixport
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tyler Schneider
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: csv
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '3.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: keystone_ui
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 0.38.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 0.38.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: railties
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: activerecord
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '7.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '7.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: activesupport
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '7.0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '7.0'
82
+ description: A Rails engine that lets a person import records into a Rails app from
83
+ a CSV file and export them to one.
84
+ email:
85
+ - tylercschneider@gmail.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - MIT-LICENSE
91
+ - README.md
92
+ - Rakefile
93
+ - app/controllers/ixport/application_controller.rb
94
+ - app/controllers/ixport/imports_controller.rb
95
+ - app/controllers/ixport/mappings_controller.rb
96
+ - app/controllers/ixport/previews_controller.rb
97
+ - app/controllers/ixport/runs_controller.rb
98
+ - app/models/ixport/application_record.rb
99
+ - app/models/ixport/import.rb
100
+ - app/models/ixport/import_refusal.rb
101
+ - app/models/ixport/row_walk.rb
102
+ - app/views/ixport/imports/index.html.erb
103
+ - app/views/ixport/imports/new.html.erb
104
+ - app/views/ixport/imports/show.html.erb
105
+ - app/views/ixport/mappings/show.html.erb
106
+ - app/views/ixport/previews/show.html.erb
107
+ - config/routes.rb
108
+ - lib/generators/ixport/install/install_generator.rb
109
+ - lib/generators/ixport/install/templates/create_ixport_import_refusals.rb.erb
110
+ - lib/generators/ixport/install/templates/create_ixport_imports.rb.erb
111
+ - lib/ixport.rb
112
+ - lib/ixport/configuration.rb
113
+ - lib/ixport/engine.rb
114
+ - lib/ixport/importer.rb
115
+ - lib/ixport/registrations.rb
116
+ - lib/ixport/version.rb
117
+ - the_local/agents/ixport-develop.md
118
+ - the_local/agents/ixport-info.md
119
+ - the_local/agents/ixport-install.md
120
+ - the_local/interface.yml
121
+ homepage: https://github.com/DYB-Development/ixport
122
+ licenses:
123
+ - MIT
124
+ metadata:
125
+ homepage_uri: https://github.com/DYB-Development/ixport
126
+ source_code_uri: https://github.com/DYB-Development/ixport
127
+ changelog_uri: https://github.com/DYB-Development/ixport/blob/main/CHANGELOG.md
128
+ bug_tracker_uri: https://github.com/DYB-Development/ixport/issues
129
+ documentation_uri: https://github.com/DYB-Development/ixport#readme
130
+ rubygems_mfa_required: 'true'
131
+ rdoc_options: []
132
+ require_paths:
133
+ - lib
134
+ required_ruby_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: 3.1.0
139
+ required_rubygems_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ requirements: []
145
+ rubygems_version: 4.0.20
146
+ specification_version: 4
147
+ summary: CSV import and export for Rails apps.
148
+ test_files: []