importo 2.0.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +99 -0
  4. data/Rakefile +39 -0
  5. data/app/assets/config/importo_manifest.js +2 -0
  6. data/app/assets/javascripts/importo/application.js +13 -0
  7. data/app/assets/stylesheets/importo/application.css +15 -0
  8. data/app/controllers/concerns/maintenance_standards.rb +24 -0
  9. data/app/controllers/importo/application_controller.rb +8 -0
  10. data/app/controllers/importo/imports_controller.rb +65 -0
  11. data/app/helpers/importo/application_helper.rb +17 -0
  12. data/app/importers/concerns/exportable.rb +128 -0
  13. data/app/importers/concerns/importable.rb +168 -0
  14. data/app/importers/concerns/importer_dsl.rb +122 -0
  15. data/app/importers/concerns/original.rb +150 -0
  16. data/app/importers/concerns/result_feedback.rb +69 -0
  17. data/app/importers/concerns/revertable.rb +41 -0
  18. data/app/importers/importo/base_importer.rb +32 -0
  19. data/app/mailers/importo/application_mailer.rb +8 -0
  20. data/app/models/importo/application_record.rb +7 -0
  21. data/app/models/importo/import.rb +93 -0
  22. data/app/services/importo/application_context.rb +6 -0
  23. data/app/services/importo/application_service.rb +9 -0
  24. data/app/services/importo/callback_service.rb +14 -0
  25. data/app/services/importo/import_context.rb +9 -0
  26. data/app/services/importo/import_service.rb +15 -0
  27. data/app/services/importo/revert_service.rb +14 -0
  28. data/app/tables/importo/imports_table.rb +39 -0
  29. data/app/views/importo/imports/index.html.slim +2 -0
  30. data/app/views/importo/imports/new.html.slim +24 -0
  31. data/config/locales/en.yml +33 -0
  32. data/config/locales/nl.yml +28 -0
  33. data/config/routes.rb +13 -0
  34. data/db/migrate/20180409151031_create_importo_import.rb +21 -0
  35. data/db/migrate/20180628175535_add_locale_importo_import.rb +7 -0
  36. data/db/migrate/20190827093548_add_selected_fields_to_import.rb +5 -0
  37. data/lib/generators/importo/USAGE +8 -0
  38. data/lib/generators/importo/importer_generator.rb +10 -0
  39. data/lib/generators/importo/install_generator.rb +27 -0
  40. data/lib/generators/templates/README +14 -0
  41. data/lib/generators/templates/application_importer.rb +4 -0
  42. data/lib/generators/templates/importer.rb +24 -0
  43. data/lib/generators/templates/importo.rb +21 -0
  44. data/lib/importo/acts_as_import_owner.rb +11 -0
  45. data/lib/importo/configuration.rb +68 -0
  46. data/lib/importo/engine.rb +23 -0
  47. data/lib/importo/import_column.rb +55 -0
  48. data/lib/importo/import_helpers.rb +10 -0
  49. data/lib/importo/version.rb +5 -0
  50. data/lib/importo.rb +29 -0
  51. metadata +332 -0
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module ImporterDsl
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ delegate :allow_revert?, :overridable_columns, to: :class
10
+ end
11
+
12
+ class_methods do
13
+ def friendly_name(friendly_name = nil)
14
+ @friendly_name = friendly_name if friendly_name
15
+ @friendly_name || model || name
16
+ end
17
+
18
+ def introduction(introduction = nil)
19
+ @introduction ||= []
20
+ @introduction = introduction if introduction
21
+ @introduction
22
+ end
23
+
24
+ def columns
25
+ @columns ||= {}
26
+ @columns
27
+ end
28
+
29
+ #
30
+ # Adds a column definition
31
+ #
32
+ # @param [Object] args
33
+ # @param [Object] block which will filter the results before storing the value in the attribute, this is useful for lookups or reformatting
34
+ def column(*args, &block)
35
+ options = args.extract_options!
36
+
37
+ name = args[0]
38
+ name ||= options[:name]
39
+ name ||= options[:attribute]
40
+
41
+ hint = args[1]
42
+ hint ||= options[:hint]
43
+
44
+ options[:scope] = self.name.underscore.to_s.tr('/', '.').to_sym
45
+
46
+ columns[name] = Importo::ImportColumn.new(name, hint, options[:explanation], options, &block)
47
+ end
48
+
49
+ def model(model = nil)
50
+ @model = model if model
51
+ @model
52
+ end
53
+
54
+ def allow_duplicates(duplicates)
55
+ @allow_duplicates = duplicates if duplicates
56
+ @allow_duplicates
57
+ end
58
+
59
+ def allow_revert(allow)
60
+ @allow_revert = allow
61
+ end
62
+
63
+ def allow_export(allow)
64
+ @allow_export = allow
65
+ end
66
+
67
+ def includes_header(includes_header)
68
+ @includes_header = includes_header if includes_header
69
+ @includes_header
70
+ end
71
+
72
+ def ignore_header(ignore_header)
73
+ @ignore_header = ignore_header if ignore_header
74
+ @ignore_header
75
+ end
76
+
77
+ def csv_options(csv_options = nil)
78
+ @csv_options = csv_options if csv_options
79
+ @csv_options
80
+ end
81
+
82
+ ##
83
+ # Set to true to allow duplicate rows to be processed, if false (default) duplicate rows will be marked duplicate and ignored.
84
+ #
85
+ def allow_duplicates?
86
+ @allow_duplicates
87
+ end
88
+
89
+ ##
90
+ # Set to true when a header is/needs to be present in the file.
91
+ #
92
+ def includes_header?
93
+ @includes_header
94
+ end
95
+
96
+ ##
97
+ # Allow reverting the import
98
+ # by default the successfully created records will be destroyed, override this behaviour with the undo method
99
+ #
100
+ def allow_revert?
101
+ @allow_revert
102
+ end
103
+
104
+ ##
105
+ # Allow exporting data
106
+ #
107
+ def allow_export?
108
+ @allow_export
109
+ end
110
+
111
+ ##
112
+ # Set to true when we need to ignore the header for structure check
113
+ #
114
+ def ignore_header?
115
+ @ignore_header
116
+ end
117
+
118
+ def overridable_columns
119
+ columns.select { |_name, column| column.overridable? }&.map(&:last)
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,150 @@
1
+
2
+ # frozen_string_literal: true
3
+
4
+ require 'active_support/concern'
5
+
6
+ module Original
7
+ extend ActiveSupport::Concern
8
+
9
+ def original
10
+ return @original if @original && !@original.is_a?(Hash)
11
+
12
+ if import.respond_to?(:attachment_changes) && import.attachment_changes['original']
13
+ @original ||= import.attachment_changes['original']&.attachable
14
+
15
+ if @original.is_a?(Hash)
16
+ tempfile = Tempfile.new(['ActiveStorage', import.original.filename.extension_with_delimiter])
17
+ tempfile.binmode
18
+ tempfile.write(@original[:io].read)
19
+ @original[:io].rewind
20
+ tempfile.rewind
21
+ @original = tempfile
22
+ end
23
+ else
24
+ return unless import&.original&.attachment
25
+
26
+ @original = Tempfile.new(['ActiveStorage', import.original.filename.extension_with_delimiter])
27
+ @original.binmode
28
+ import.original.download { |block| @original.write(block) }
29
+ @original.flush
30
+ @original.rewind
31
+ end
32
+
33
+ @original
34
+ end
35
+
36
+ def structure_valid?
37
+ return true if !includes_header? || ignore_header?
38
+
39
+ invalid_header_names.count.zero?
40
+ end
41
+
42
+ def invalid_header_names
43
+ invalid_header_names_for_row(header_row)
44
+ end
45
+
46
+ def col_for(translated_name)
47
+ col = columns.detect { |k, v| v.name == translated_name || k == translated_name }
48
+ col ||= columns.detect { |k, v| v.allowed_names.include?(translated_name) }
49
+ col
50
+ end
51
+
52
+ private
53
+
54
+ def headers_added_by_import
55
+ %w[import_state import_created_id import_message import_errors].map(&:dup)
56
+ end
57
+
58
+ def cells_from_row(index, clean = true)
59
+ spreadsheet.row(index).map { |c| clean ? cleaned_data_from_cell(c) : c }
60
+ end
61
+
62
+ def cleaned_data_from_cell(cell)
63
+ return cell unless cell.respond_to?(:strip)
64
+
65
+ strip_tags cell.strip
66
+ end
67
+
68
+ def data_start_row
69
+ header_row + 1
70
+ end
71
+
72
+ def header_row
73
+ return 0 unless includes_header?
74
+ return @header_row if @header_row
75
+
76
+ most_valid_counts = (1..20).map do |row_nr|
77
+ [row_nr, cells_from_row(row_nr).reject(&:nil?).size - invalid_header_names_for_row(row_nr).size]
78
+ end
79
+ @header_row = most_valid_counts.max_by(&:last).first
80
+ end
81
+
82
+ def invalid_header_names_for_row(index)
83
+ stripped_headers = allowed_header_names.map { |name| name.to_s.gsub(/[^A-Za-z]/, '').downcase }
84
+ cells_from_row(index).reject { |header| stripped_headers.include?(header.to_s.gsub(/[^A-Za-z]/, '').downcase) }
85
+ end
86
+
87
+ def allowed_header_names
88
+ @allowed_header_names ||= columns.values.map(&:allowed_names).flatten + headers_added_by_import
89
+ end
90
+
91
+ def spreadsheet
92
+ @spreadsheet ||= case File.extname(original.path)
93
+ when '.csv' then
94
+ Roo::CSV.new(original.path, csv_options: csv_options)
95
+ when '.xls' then
96
+ Roo::Excel.new(original.path)
97
+ when '.xlsx' then
98
+ Roo::Excelx.new(original.path)
99
+ else
100
+ raise "Unknown file type: #{original.path.split('/').last}"
101
+ end
102
+ end
103
+
104
+ def duplicate(row_hash, id)
105
+ Importo::Import.where("results @> '[{\"hash\": \"#{row_hash}\", \"state\": \"success\"}]' AND id <> :id", id: id).first
106
+ end
107
+
108
+ def duplicate?(row_hash, id)
109
+ return false if allow_duplicates? || row_hash['id'] == id
110
+
111
+ duplicate(row_hash, id)
112
+ end
113
+
114
+ def loop_data_rows
115
+ (data_start_row..spreadsheet.last_row).map do |index|
116
+ row = cells_from_row(index, false)
117
+ attributes = Hash[[attribute_names, row].transpose]
118
+ attributes = attributes.map do |column, value|
119
+ value = strip_tags(value.strip) if value.respond_to?(:strip) && columns[column]&.options[:strip_tags] != false
120
+ [column, value]
121
+ end.to_h
122
+ attributes.reject! { |k, _v| headers_added_by_import.include?(k) }
123
+
124
+ yield attributes, index
125
+ end
126
+ end
127
+
128
+ def row_count
129
+ (spreadsheet.last_row - data_start_row) + 1
130
+ end
131
+
132
+ def nr_to_col(number)
133
+ ('A'..'ZZ').to_a[number]
134
+ end
135
+
136
+ def attribute_names
137
+ return columns.keys if !includes_header? || ignore_header?
138
+
139
+ translated_header_names = cells_from_row(header_row)
140
+ @header_names = translated_header_names.map do |name|
141
+ col_for(name)&.first
142
+ end
143
+ end
144
+
145
+ def header_names
146
+ return columns.values.map(&:name) if !includes_header? || ignore_header?
147
+
148
+ @header_names ||= cells_from_row(header_row)
149
+ end
150
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module ResultFeedback
6
+ extend ActiveSupport::Concern
7
+
8
+
9
+ #
10
+ # Generates a result excel file as a stream
11
+ #
12
+ def results_file
13
+ xls = Axlsx::Package.new
14
+ xls.use_shared_strings = true
15
+ workbook = xls.workbook
16
+ workbook.styles do |style|
17
+ alert_cell = style.add_style(bg_color: 'dd7777')
18
+ duplicate_cell = style.add_style(bg_color: 'ddd777')
19
+
20
+ sheet = workbook.add_worksheet(name: I18n.t('importo.sheet.results.name'))
21
+
22
+ headers = (header_names - headers_added_by_import) + headers_added_by_import
23
+ rich_text_headers = headers.map { |header| Axlsx::RichText.new.tap { |rt| rt.add_run(header.dup, b: true) } }
24
+ sheet.add_row rich_text_headers
25
+ loop_data_rows do |attributes, index|
26
+ row_state = result(index, 'state')
27
+
28
+ style = case row_state
29
+ when 'duplicate'
30
+ duplicate_cell
31
+ when 'failure'
32
+ alert_cell
33
+ end
34
+ sheet.add_row attributes.values + results(index), style: Array.new(attributes.values.count) + Array.new(headers_added_by_import.count) { style }
35
+ end
36
+
37
+ sheet.auto_filter = "A1:#{sheet.dimension.last_cell_reference}"
38
+ end
39
+
40
+ xls.to_stream
41
+ end
42
+
43
+ def file_name(suffix = nil)
44
+ base = friendly_name || model.class.name
45
+ base = base.to_s unless base.is_a?(String)
46
+ base = base.gsub(/[_\s-]/, '_').pluralize.downcase
47
+ "#{base}#{suffix.present? ? "_#{suffix}" : '' }.xlsx"
48
+ end
49
+
50
+ private
51
+
52
+ def register_result(index, details)
53
+ @import.results ||= []
54
+ i = @import.results.index { |data| data[:row] == index }
55
+ if i
56
+ @import.results[i].merge!(details)
57
+ else
58
+ @import.results << details.merge(row: index)
59
+ end
60
+ end
61
+
62
+ def results(index)
63
+ [result(index, :state), result(index, :id), result(index, :message), result(index, :errors)]
64
+ end
65
+
66
+ def result(index, field)
67
+ (@import.results.find { |result| result[:row] == index } || {}).fetch(field, nil)
68
+ end
69
+ end
@@ -0,0 +1,41 @@
1
+
2
+ # frozen_string_literal: true
3
+
4
+ require 'active_support/concern'
5
+
6
+ module Revertable
7
+ extend ActiveSupport::Concern
8
+
9
+ def revert!
10
+ undo_all
11
+
12
+ import.reverted!
13
+ rescue StandardError => e
14
+ import.result_message = "Exception: #{e.message}"
15
+ Rails.logger.error "Importo exception: #{e.message} backtrace #{e.backtrace.join(';')}"
16
+ import.failure!
17
+ end
18
+
19
+ private
20
+
21
+ def undo_all
22
+ revertable_results = import.results.select { |result| result['state'] == 'success' }
23
+
24
+ revertable_results.each do |revertable_result|
25
+ next unless revertable_result['state'] == 'success'
26
+
27
+ begin
28
+ undo(revertable_result['class'], revertable_result['id'], cells_from_row(revertable_result['row']))
29
+ revertable_result['state'] = 'reverted'
30
+ revertable_result.delete('message')
31
+ revertable_result.delete('errors')
32
+ rescue StandardError => e
33
+ result['message'] = "Not reverted: #{e.message}"
34
+ end
35
+ end
36
+ end
37
+
38
+ def undo_row(klass, id, _row)
39
+ klass.constantize.find(id).destroy
40
+ end
41
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Importo
4
+ class BaseImporter
5
+ include ActionView::Helpers::SanitizeHelper
6
+ include Importable
7
+ include Exportable
8
+ include Revertable
9
+ include Original
10
+ include ResultFeedback
11
+ include ImporterDsl
12
+ # include ActiveStorage::Downloading
13
+
14
+ delegate :friendly_name, :introduction, :model, :columns, :csv_options, :allow_duplicates?, :includes_header?,
15
+ :ignore_header?, :t, to: :class
16
+ attr_reader :import, :blob
17
+
18
+ def initialize(imprt = nil)
19
+ @import = imprt
20
+ I18n.locale = import.locale if import&.locale # Should we do this?? here??
21
+ end
22
+
23
+ class << self
24
+ def t(key, options = {})
25
+ if I18n.exists? "importers.#{name.underscore}#{key}".to_sym
26
+ I18n.t(key,
27
+ options.merge(scope: "importers.#{name.underscore}".to_sym))
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Importo
4
+ class ApplicationMailer < ActionMailer::Base
5
+ default from: 'from@example.com'
6
+ layout 'mailer'
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Importo
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Importo
4
+ class Import < Importo::ApplicationRecord
5
+ # include ActiveStorage::Downloading
6
+
7
+ belongs_to :importo_ownable, polymorphic: true
8
+
9
+ has_many :message_instances, as: :messagable
10
+
11
+ validates :kind, presence: true
12
+ validates :original, presence: true
13
+ validate :content_validator
14
+
15
+ begin
16
+ has_one_attached :original
17
+ has_one_attached :result
18
+ rescue NoMethodError
19
+ # Weird loading sequence error, is fixed by the lib/importo/helpers
20
+ end
21
+
22
+ state_machine :state, initial: :new do
23
+ audit_trail class: ResourceStateTransition, as: :resource if "ResourceStateTransition".safe_constantize
24
+
25
+ state :importing
26
+ state :scheduled
27
+ state :completed
28
+ state :failed
29
+ state :reverted
30
+
31
+ after_transition any => any do |imprt, transition|
32
+ CallbackService.perform_later(import: imprt, callback: transition.to_name)
33
+ end
34
+
35
+ after_transition any => :scheduled, do: :schedule_import
36
+ after_transition any => :reverting, do: :schedule_revert
37
+
38
+ event :schedule do
39
+ transition new: :scheduled
40
+ end
41
+
42
+ event :import do
43
+ transition new: :importing
44
+ transition scheduled: :importing
45
+ transition failed: :importing
46
+ end
47
+
48
+ event :complete do
49
+ transition importing: :completed
50
+ end
51
+
52
+ event :failure do
53
+ transition any => :failed
54
+ end
55
+
56
+ event :revert do
57
+ transition completed: :reverting
58
+ end
59
+
60
+ event :revert do
61
+ transition reverting: :reverted
62
+ end
63
+ end
64
+
65
+ def can_revert?
66
+ importer.allow_revert? && super
67
+ end
68
+
69
+ def allow_export?
70
+ importer.class.allow_export?
71
+ end
72
+
73
+ def content_validator
74
+ errors.add(:original, I18n.t('importo.errors.structure_invalid', invalid_headers: importer.invalid_header_names.join(', '))) unless importer.structure_valid?
75
+ rescue => e
76
+ errors.add(:original, I18n.t('importo.errors.parse_error', error: e.message))
77
+ end
78
+
79
+ def importer
80
+ @importer ||= "#{kind.camelize}Importer".constantize.new(self)
81
+ end
82
+
83
+ private
84
+
85
+ def schedule_import
86
+ ImportService.perform_later(import: self)
87
+ end
88
+
89
+ def schedule_revert
90
+ RevertService.perform_later(import: self)
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Importo
4
+ class ApplicationContext < Importo.config.base_service_context.constantize
5
+ end
6
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Importo
4
+ class ApplicationService < Importo.config.base_service.constantize
5
+ def self.queue_name
6
+ Importo.config.queue_name
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Importo
4
+ class CallbackService < ApplicationService
5
+ context do
6
+ attribute :import
7
+ attribute :callback
8
+ end
9
+
10
+ def perform
11
+ Importo.config.import_callback(context.import, context.callback)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Importo
4
+ class ImportContext < ApplicationContext
5
+ input do
6
+ attribute :import, type: Import, typecaster: ->(value) { value.is_a?(Import) ? value : Import.find(value) }
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Importo
4
+ class ImportService < ApplicationService
5
+ def perform
6
+ sleep 1
7
+
8
+ context.import.import!
9
+ context.import.importer.import!
10
+ rescue StandardError
11
+ context.import.failure!
12
+ context.fail!
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Importo
4
+ class RevertService < ApplicationService
5
+ def perform
6
+ sleep 1
7
+
8
+ context.import.importer.revert!
9
+ rescue StandardError
10
+ context.import.failure!
11
+ context.fail!
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Importo::ImportsTable < ActionTable::ActionTable
4
+ model Importo::Import
5
+
6
+ column(:created_at) { |import| l(import.created_at.in_time_zone(Time.zone), format: :short).to_s }
7
+ column(:user, filter: { parameter: :ownable, collection_proc: -> { Importo::Import.order(created_at: :desc).limit(200).map(&:importo_ownable).uniq.sort_by(&:name).map { |o| [o.name, "#{o.class.name}##{o.id}"] } } } ) { |import| import.importo_ownable.name }
8
+ column(:kind, sortable: false)
9
+ column(:original, sortable: false) { |import| link_to(import.original.filename, main_app.rails_blob_path(import.original, disposition: "attachment"), target: '_blank') }
10
+ column(:state)
11
+ column(:result, sortable: false) { |import| import.result.attached? ? link_to(import.result_message, main_app.rails_blob_path(import.result, disposition: "attachment"), target: '_blank') : import.result_message }
12
+ column(:extra_links, sortable: false) { |import| Importo.config.admin_extra_links(import).map { |name, link| link_to(link[:text], link[:url], title: link[:title], target: '_blank', class: link[:icon]) }}
13
+
14
+ column :actions, title: '', sortable: false do |import|
15
+ content_tag(:span) do
16
+ if import.can_revert?
17
+ concat link_to(content_tag(:i, nil, class: 'fa fa-undo'), importo.undo_import_path(import), data: { turbo_method: :post, turbo_confirm: 'Are you sure? This will undo this import.' })
18
+ end
19
+ if Importo.config.admin_can_destroy(import)
20
+ concat link_to(content_tag(:i, nil, class: 'fa fa-trash'), importo.import_path(import), class: 'float-right', data: { turbo_method: :delete, turbo_confirm: 'Are you sure? This will prevent duplicate imports from being detected.' })
21
+ end
22
+ end
23
+ end
24
+
25
+ initial_order :created_at, :desc
26
+
27
+ private
28
+
29
+ def scope
30
+ @scope = Importo.config.admin_visible_imports
31
+ end
32
+
33
+ def filtered_scope
34
+ @filtered_scope = scope
35
+ @filtered_scope = @filtered_scope.where(importo_ownable_type: params[:ownable].split('#').first, importo_ownable_id: params[:ownable].split('#').last) if params[:ownable]
36
+
37
+ @filtered_scope
38
+ end
39
+ end
@@ -0,0 +1,2 @@
1
+ = sts.card title: t('.title'), icon: 'fad fa-file-import', content_padding: false do |card|
2
+ = sts.table :importo_imports
@@ -0,0 +1,24 @@
1
+ = sts.form_for(@import) do |f|
2
+ = f.input :kind, as: :hidden
3
+ = sts.card title: t('.title'), icon: 'fad fa-file-spreadsheet' do |card|
4
+ - card.action
5
+ = f.submit
6
+
7
+ .grid.grid-cols-12.gap-4
8
+ .col-span-12
9
+ .prose
10
+ p= t('.explanation_html', name: @import.importer.class.friendly_name, sample_path: sample_import_path(kind: @import.kind))
11
+ - if @import.allow_export?
12
+ p= t('.export_html', export_path: export_path(kind: @import.kind))
13
+
14
+ .col-span-12
15
+ - @import.importer.overridable_columns.each do |column|
16
+ = f.fields_for :column_overrides do |fff|
17
+ - if column.collection
18
+ = fff.input column.attribute, as: :select, label: column.name, collection: column.collection, include_blank: true, required: false
19
+ - else
20
+ = fff.input column.attribute, label: column.name, required: false
21
+
22
+ .col-span-12
23
+ = f.input :original, as: :file
24
+