importo 2.0.4

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 (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
+