labimotion 2.3.0 → 2.4.0.rc11

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -1
  3. data/lib/labimotion/apis/generic_dataset_api.rb +93 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +198 -8
  5. data/lib/labimotion/apis/generic_klass_api.rb +74 -8
  6. data/lib/labimotion/apis/klass_share_api.rb +648 -0
  7. data/lib/labimotion/apis/labimotion_ai_api.rb +252 -0
  8. data/lib/labimotion/apis/labimotion_api.rb +4 -0
  9. data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
  10. data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
  11. data/lib/labimotion/apis/ontology_root_api.rb +74 -0
  12. data/lib/labimotion/apis/segment_api.rb +77 -10
  13. data/lib/labimotion/apis/user_klass_settings_api.rb +93 -0
  14. data/lib/labimotion/conf.rb +5 -0
  15. data/lib/labimotion/constants.rb +64 -0
  16. data/lib/labimotion/entities/application_entity.rb +8 -0
  17. data/lib/labimotion/entities/eln_element_entity.rb +6 -0
  18. data/lib/labimotion/entities/generic_klass_entity.rb +126 -0
  19. data/lib/labimotion/entities/klass_share_entity.rb +48 -0
  20. data/lib/labimotion/entities/properties_entity.rb +78 -2
  21. data/lib/labimotion/entities/segment_entity.rb +8 -0
  22. data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
  23. data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
  24. data/lib/labimotion/helpers/dataset_helpers.rb +187 -1
  25. data/lib/labimotion/helpers/element_helpers.rb +282 -6
  26. data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
  27. data/lib/labimotion/helpers/generic_helpers.rb +293 -4
  28. data/lib/labimotion/helpers/param_helpers.rb +105 -0
  29. data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
  30. data/lib/labimotion/helpers/segment_helpers.rb +102 -4
  31. data/lib/labimotion/libs/ai_egress_guard.rb +99 -0
  32. data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
  33. data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
  34. data/lib/labimotion/libs/ai_models.rb +201 -0
  35. data/lib/labimotion/libs/ai_template.rb +2285 -0
  36. data/lib/labimotion/libs/converter.rb +5 -43
  37. data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
  38. data/lib/labimotion/libs/export_element.rb +128 -13
  39. data/lib/labimotion/libs/file_extractor.rb +210 -0
  40. data/lib/labimotion/libs/linked_element.rb +313 -0
  41. data/lib/labimotion/libs/ontology_store.rb +226 -0
  42. data/lib/labimotion/libs/ontology_terms.rb +227 -0
  43. data/lib/labimotion/libs/owner_resolver.rb +50 -0
  44. data/lib/labimotion/libs/ownership_audit.rb +73 -0
  45. data/lib/labimotion/libs/sample_association.rb +52 -1
  46. data/lib/labimotion/libs/share_notifier.rb +114 -0
  47. data/lib/labimotion/libs/share_resolver.rb +373 -0
  48. data/lib/labimotion/libs/user_ai_settings.rb +129 -0
  49. data/lib/labimotion/models/cellline.rb +47 -0
  50. data/lib/labimotion/models/concerns/datasetable.rb +3 -0
  51. data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
  52. data/lib/labimotion/models/concerns/segmentable.rb +2 -0
  53. data/lib/labimotion/models/concerns/template_doi.rb +133 -0
  54. data/lib/labimotion/models/dataset_klass.rb +1 -1
  55. data/lib/labimotion/models/element_klass.rb +1 -1
  56. data/lib/labimotion/models/klass_share.rb +129 -0
  57. data/lib/labimotion/models/segment_klass.rb +1 -1
  58. data/lib/labimotion/models/user_klass_setting.rb +58 -0
  59. data/lib/labimotion/models/user_setting.rb +60 -0
  60. data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
  61. data/lib/labimotion/usecases/release_template_doi.rb +42 -17
  62. data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
  63. data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
  64. data/lib/labimotion/utils/export_utils.rb +1 -0
  65. data/lib/labimotion/utils/import_utils.rb +20 -3
  66. data/lib/labimotion/utils/serializer.rb +27 -0
  67. data/lib/labimotion/utils/units.rb +32 -67
  68. data/lib/labimotion/version.rb +1 -1
  69. data/lib/labimotion.rb +27 -0
  70. metadata +45 -3
@@ -3,11 +3,16 @@
3
3
  require 'grape'
4
4
  require 'labimotion/conf'
5
5
  require 'labimotion/utils/utils'
6
+ require 'labimotion/helpers/cover_image_helpers'
6
7
  # require 'labimotion/models/element_klass'
7
8
  module Labimotion
8
9
  ## ElementHelpers
9
10
  module ElementHelpers
10
11
  extend Grape::API::Helpers
12
+ # the element save validates metadata.cover_images up front through
13
+ # prepare_element_metadata — carry the dependency instead of relying on
14
+ # both modules being mounted on the same Grape endpoint
15
+ include Labimotion::CoverImageHelpers
11
16
 
12
17
  def klass_list(is_generic_only)
13
18
  if is_generic_only == true
@@ -31,7 +36,13 @@ module Labimotion
31
36
  attributes[:properties_release] = attributes[:properties_template]
32
37
  attributes[:created_by] = current_user.id
33
38
 
34
- new_klass = Labimotion::ElementKlass.create!(attributes)
39
+ # The owner row shares the create's transaction: a klass committed without it is
40
+ # owner-less, which falls open to the legacy designer-wide gate.
41
+ new_klass = Labimotion::ElementKlass.transaction do
42
+ Labimotion::ElementKlass.create!(attributes).tap do |klz|
43
+ Labimotion::KlassShare.seed_owner!(klz, current_user.id)
44
+ end
45
+ end
35
46
  new_klass.reload
36
47
  new_klass.create_klasses_revision(current_user)
37
48
  klass_names_file = Labimotion::KLASSES_JSON # Rails.root.join('app/packs/klasses.json')
@@ -43,6 +54,249 @@ module Labimotion
43
54
  raise e
44
55
  end
45
56
 
57
+ # Create a new (inactive) element klass whose properties template is
58
+ # generated by an LLM from a user-provided name/label plus optional
59
+ # description, reference links and uploaded files. The admin reviews/edits
60
+ # the generated template in the designer and activates it (human-in-the-loop).
61
+ def create_ai_element_klass(params, current_user)
62
+ name = params[:name]
63
+ label = params[:label]
64
+ if Labimotion::ElementKlass.find_by(name: name).present?
65
+ return { status: 'error', message: "An element named [#{name}] already exists." }
66
+ end
67
+
68
+ if Array(params[:files]).size > Labimotion::AiTemplate::MAX_FILES
69
+ return { status: 'error', message: "Too many files (max #{Labimotion::AiTemplate::MAX_FILES})." }
70
+ end
71
+
72
+ overrides = ai_user_overrides(current_user)
73
+ ai = Labimotion::AiTemplate.generate(
74
+ kind: 'element',
75
+ subject: "#{params[:label]} (#{params[:name]})",
76
+ desc: params[:desc],
77
+ cols: params[:cols],
78
+ references: params[:references],
79
+ files: params[:files],
80
+ **overrides
81
+ )
82
+
83
+ uuid = SecureRandom.uuid
84
+ # property-base schema requires pkg, uuid, klass, layers, version, identifier.
85
+ properties_template = {
86
+ 'uuid' => uuid,
87
+ 'klass' => 'ElementKlass',
88
+ 'pkg' => Labimotion::Utils.pkg(nil),
89
+ 'version' => '1.0.0',
90
+ 'identifier' => uuid,
91
+ 'layers' => ai['layers'],
92
+ 'select_options' => ai['select_options'],
93
+ 'metadata' => ai['metadata']
94
+ }
95
+ attributes = {
96
+ 'name' => name,
97
+ 'label' => label,
98
+ 'klass_prefix' => params[:klass_prefix],
99
+ 'icon_name' => params[:icon_name],
100
+ 'desc' => params[:desc].presence || ai['label'].presence,
101
+ 'is_active' => false,
102
+ 'uuid' => uuid,
103
+ 'released_at' => DateTime.now,
104
+ 'properties_template' => properties_template,
105
+ 'properties_release' => properties_template,
106
+ 'created_by' => current_user.id
107
+ }
108
+
109
+ # Same as the non-AI create beside it: the owner row shares the create's
110
+ # transaction, because a klass committed without one is owner-less and
111
+ # falls OPEN to the legacy designer-wide gate — every designer of the
112
+ # family could then edit, release and delete it, while its creator holds
113
+ # no special standing at all. seed_owner! rescues only RecordNotUnique and
114
+ # RecordInvalid, so any other failure has to take the klass down with it;
115
+ # this helper turns every StandardError into { status: 'error' }, and
116
+ # without the transaction the caller would be told the create failed while
117
+ # an unowned template silently persisted.
118
+ new_klass = Labimotion::ElementKlass.transaction do
119
+ Labimotion::ElementKlass.create!(attributes).tap do |klz|
120
+ Labimotion::KlassShare.seed_owner!(klz, current_user.id)
121
+ end
122
+ end
123
+ new_klass.reload
124
+ new_klass.create_klasses_revision(current_user)
125
+ klass_names_file = Labimotion::KLASSES_JSON # Rails.root.join('app/packs/klasses.json')
126
+ klasses = Labimotion::ElementKlass.where(is_active: true)&.pluck(:name) || []
127
+ File.write(klass_names_file, klasses)
128
+ # `record` is the row itself. The background job that calls this needs the
129
+ # template, not a sentence about it, to link the notification back to it.
130
+ { status: 'success', record: new_klass,
131
+ message: "The AI element template [#{label}] has been created as inactive. Review and activate it in the designer." }
132
+ rescue StandardError => e
133
+ Labimotion.log_exception(e, current_user)
134
+ { status: 'error', message: e.message }
135
+ end
136
+
137
+ # AI auto-fill of a generic element instance's DATA VALUES from a document
138
+ # (an existing attachment, an analysis, or freshly uploaded files). Reads the
139
+ # source text, asks the LLM to extract values for the element's OWN template
140
+ # fields, validates them, and returns them for the client to merge into the
141
+ # working copy for human review. Nothing is persisted here.
142
+ #
143
+ # Authorization: the caller must be able to READ the element, and the source
144
+ # must BELONG to that element (see the ownership checks below) — this endpoint
145
+ # reads files by id, so it must never read an Attachment/Container the user
146
+ # does not already have access to through this element.
147
+ def ai_fill_element_data(params, current_user)
148
+ element = Labimotion::Element.find(params[:element_id])
149
+ error!('401 Unauthorized', 401) unless ElementPolicy.new(current_user, element).read?
150
+
151
+ text = ai_fill_source_text(element, params)
152
+ overrides = ai_user_overrides(current_user)
153
+ ai = Labimotion::AiTemplate.fill(
154
+ properties: ai_fill_properties(element),
155
+ context_text: text,
156
+ instructions: ai_fill_instructions(element),
157
+ **overrides
158
+ )
159
+ { status: 'success', values: ai['values'], summary: ai['summary'], usage: ai['usage'], model: ai['model'] }
160
+ rescue StandardError => e
161
+ Labimotion.log_exception(e, current_user)
162
+ { status: 'error', message: e.message }
163
+ end
164
+
165
+ # The element's properties, with the select_options its own fields point at.
166
+ #
167
+ # An element instance never stores select_options: create and update both
168
+ # delete the key (see create_element/update_element below), because the
169
+ # options belong to the template, not to the instance. The fill prompt lists
170
+ # a select's allowed options so the model can pick one, and reads them out
171
+ # of the properties it is handed — so passing element.properties straight
172
+ # through offered the model an EMPTY list for every select and select-multi
173
+ # field. Nothing to choose from, so those fields came back unfilled, which
174
+ # reads as the AI having ignored them.
175
+ #
176
+ # properties_release, not the klass's current properties_template: it is the
177
+ # release this element was actually built against, so its option keys are the
178
+ # ones the element's fields reference and the ones the form will accept back.
179
+ # Same source the UI and the exporter read for this element.
180
+ def ai_fill_properties(element)
181
+ props = element.properties
182
+ return props unless props.is_a?(Hash)
183
+ return props if props['select_options'].present?
184
+
185
+ release = element.properties_release
186
+ options = release.is_a?(Hash) ? release['select_options'] : nil
187
+ return props if options.blank?
188
+
189
+ props.merge('select_options' => options)
190
+ end
191
+
192
+ # The template's own auto-fill guidance, from Template settings in the designer.
193
+ # Read from the KLASS rather than the element's copied properties, so editing it
194
+ # takes effect on elements that already exist instead of only on new ones.
195
+ def ai_fill_instructions(element)
196
+ settings = element.element_klass&.settings
197
+ return nil unless settings.is_a?(Hash)
198
+
199
+ settings[Labimotion::Constants::KlassSetting::AI_FILL_PROMPT].presence
200
+ end
201
+
202
+ # Resolve the requested source into plain text, enforcing ownership first.
203
+ def ai_fill_source_text(element, params)
204
+ case params[:source]
205
+ when 'attachment'
206
+ ai_fill_attachment_text(element, params[:attachment_id])
207
+ when 'analysis'
208
+ ai_fill_analysis_text(element, params[:container_id])
209
+ when 'upload'
210
+ ai_fill_upload_text(params[:files])
211
+ when 'text'
212
+ ai_fill_pasted_text(params[:text])
213
+ else
214
+ error!('400 Bad Request', 400)
215
+ end
216
+ end
217
+
218
+ # The attachment must be one of the element's own attachments OR an attachment
219
+ # of one of the element's analysis DATASET containers. Only then is it read.
220
+ def ai_fill_attachment_text(element, attachment_id)
221
+ error!('404 Not Found', 404) if attachment_id.nil?
222
+ error!('404 Not Found', 404) unless ai_fill_element_attachment_ids(element).include?(attachment_id.to_i)
223
+
224
+ att = Attachment.find(attachment_id)
225
+ Labimotion::FileExtractor.extract_bytes(att.filename, att.read_file)
226
+ end
227
+
228
+ # The container id must be one of the element's own analysis containers. Then
229
+ # concatenate the extracted text of every attachment on that analysis's dataset
230
+ # children, each prefixed "### <filename>", with the analysis name/description
231
+ # prepended as context. Cap at AiTemplate::MAX_FILES; skip unreadable files.
232
+ def ai_fill_analysis_text(element, container_id)
233
+ error!('404 Not Found', 404) if container_id.nil?
234
+ analysis = element.analyses.detect { |c| c.id == container_id.to_i }
235
+ error!('404 Not Found', 404) if analysis.nil?
236
+
237
+ parts = []
238
+ context = [analysis.name.presence, analysis.description.presence].compact.join(' — ')
239
+ parts << "Analysis: #{context}" if context.present?
240
+
241
+ ai_fill_analysis_attachments(analysis).first(Labimotion::AiTemplate::MAX_FILES).each do |att|
242
+ content = ai_fill_extract_attachment(att)
243
+ next if content.to_s.strip.empty?
244
+
245
+ parts << "### #{att.filename}\n#{content}"
246
+ end
247
+ parts.join("\n\n")
248
+ end
249
+
250
+ def ai_fill_upload_text(files)
251
+ parts = Array(files).first(Labimotion::AiTemplate::MAX_FILES).map do |file|
252
+ name = file[:filename] || file['filename']
253
+ next if name.blank?
254
+
255
+ content = Labimotion::FileExtractor.extract(name, file[:content_base64] || file['content_base64'])
256
+ next if content.to_s.strip.empty?
257
+
258
+ "### #{name}\n#{content}"
259
+ end.compact
260
+ parts.join("\n\n")
261
+ end
262
+
263
+ # Text pasted straight into the dialog. Capped like an extracted file so a
264
+ # very long paste cannot blow the model's context or the request budget —
265
+ # the same MAX_FILE_CHARS the file path is held to.
266
+ def ai_fill_pasted_text(text)
267
+ body = text.to_s.strip
268
+ error!('400 Bad Request', 400) if body.empty?
269
+
270
+ body[0, Labimotion::AiTemplate::MAX_FILE_CHARS].to_s
271
+ end
272
+
273
+ # Ids of attachments this element may expose to the AI fill: its own
274
+ # attachments plus the attachments of its analysis dataset containers.
275
+ def ai_fill_element_attachment_ids(element)
276
+ own = element.attachments.pluck(:id)
277
+ dataset_ids = ai_fill_dataset_containers(element).map(&:id)
278
+ dataset_att = dataset_ids.empty? ? [] : Attachment.where(attachable_type: 'Container', attachable_id: dataset_ids).pluck(:id)
279
+ (own + dataset_att).uniq
280
+ end
281
+
282
+ # Dataset containers under all of the element's analysis containers.
283
+ def ai_fill_dataset_containers(element)
284
+ element.analyses.flat_map { |analysis| analysis.children.where(container_type: 'dataset').to_a }
285
+ end
286
+
287
+ def ai_fill_analysis_attachments(analysis)
288
+ analysis.children.where(container_type: 'dataset').flat_map do |dataset|
289
+ Attachment.where(attachable_type: 'Container', attachable_id: dataset.id).to_a
290
+ end
291
+ end
292
+
293
+ def ai_fill_extract_attachment(att)
294
+ Labimotion::FileExtractor.extract_bytes(att.filename, att.read_file)
295
+ rescue StandardError => e
296
+ Labimotion.log_exception(e)
297
+ nil
298
+ end
299
+
46
300
  def update_element_klass(current_user, params)
47
301
  place = params[:place] || 100
48
302
  begin
@@ -51,6 +305,7 @@ module Labimotion
51
305
  place = 100
52
306
  end
53
307
  klass = Labimotion::ElementKlass.find(params[:id])
308
+ authorize_klass!(klass, :write)
54
309
  klass.label = params[:label] if params[:label].present?
55
310
  klass.klass_prefix = params[:klass_prefix] if params[:klass_prefix].present?
56
311
  klass.icon_name = params[:icon_name] if params[:icon_name].present?
@@ -82,7 +337,7 @@ module Labimotion
82
337
  klass_uuid: klass[:uuid],
83
338
  properties: properties,
84
339
  properties_release: params[:properties_release],
85
- metadata: params[:metadata] || {},
340
+ metadata: prepare_element_metadata({}, params[:metadata]),
86
341
  created_by: current_user.id
87
342
  }
88
343
  element = Labimotion::Element.new(attributes)
@@ -110,6 +365,11 @@ module Labimotion
110
365
 
111
366
  def update_element_by_id(current_user, params)
112
367
  element = Labimotion::Element.find(params[:id])
368
+ # Validated up front, before the container/sample-association writes run:
369
+ # a rejected cover selection must fail the request whole, not leave a
370
+ # half-applied datamodel behind. error! throws rather than raises, so it
371
+ # escapes this method's rescue-and-log and reaches the client as a 422.
372
+ metadata = prepare_element_metadata(element.metadata, params[:metadata])
113
373
  update_datamodel(params[:container], current_user)
114
374
  properties = update_sample_association(params[:properties], current_user, element)
115
375
  params.delete(:container)
@@ -118,7 +378,6 @@ module Labimotion
118
378
  params.delete(:user_labels)
119
379
  attributes = declared(params.except(:segments), include_missing: false)
120
380
  properties['pkg'] = Labimotion::Utils.pkg(properties['pkg'])
121
- metadata = params[:metadata] || element.metadata || {}
122
381
  if element.klass_uuid != properties['klass_uuid'] || element.properties != properties || element.name != params[:name] || element.metadata != metadata
123
382
  properties['klass'] = 'Element'
124
383
  uuid = SecureRandom.uuid
@@ -305,12 +564,25 @@ module Labimotion
305
564
  end
306
565
 
307
566
  def validate_klass(attributes)
308
- element_klass = Labimotion::ElementKlass.find_by(identifier: attributes['identifier'])
567
+ # Guarded on presence like the segment path: a blank identifier must mean "create",
568
+ # never `find_by(identifier: nil)` — that would match an arbitrary legacy row whose
569
+ # identifier is NULL and upgrade an unrelated template.
570
+ element_klass = Labimotion::ElementKlass.find_by(identifier: attributes['identifier']) if attributes['identifier'].present?
309
571
  if element_klass.present?
310
572
  if element_klass['uuid'] == attributes['uuid'] && element_klass['version'] == attributes['version']
311
573
  return { status: 'success', message: "This element: #{attributes['name']} has the latest version!" }
312
574
  else
313
- element_klass.update!(attributes)
575
+ # Upgrading an existing template bumps its version and cuts a revision — publishing,
576
+ # which is what :release means, so it is owner-only. The gate has to live here, not
577
+ # in after_validation: which branch applies is unknown until this identifier lookup
578
+ # has run. error! throws rather than raises, so it escapes this method's rescue.
579
+ authorize_klass!(element_klass, :release)
580
+ # And it must not reassign authorship. `upload_klass` merges
581
+ # `created_by: current_user.id` into the attributes unconditionally
582
+ # (generic_element_api.rb), so without this the importer silently becomes the author
583
+ # of a template somebody else wrote — and that column is what the Owner column shows
584
+ # and what per-template ownership is seeded from.
585
+ element_klass.update!(attributes.except('created_by', :created_by))
314
586
  element_klass.create_klasses_revision(current_user)
315
587
  return { status: 'success', message: "This element: [#{attributes['name']}] has been upgraded to the version: #{attributes['version']}!" }
316
588
  end
@@ -320,7 +592,11 @@ module Labimotion
320
592
  return { status: 'error', message: "The name [#{attributes['name']}] is already in use." }
321
593
  else
322
594
  attributes['created_by'] = current_user.id
323
- element_klass = Labimotion::ElementKlass.create!(attributes)
595
+ element_klass = Labimotion::ElementKlass.transaction do
596
+ Labimotion::ElementKlass.create!(attributes).tap do |klz|
597
+ Labimotion::KlassShare.seed_owner!(klz, current_user.id)
598
+ end
599
+ end
324
600
  element_klass.create_klasses_revision(current_user)
325
601
  return { status: 'success', message: "The element: #{attributes['name']} has been created using version: #{attributes['version']}!" }
326
602
  end
@@ -13,9 +13,13 @@ module Labimotion
13
13
  col_name
14
14
  end
15
15
 
16
- # Check if field type should be expanded (DRAG_SAMPLE or DRAG_MOLECULE)
16
+ # Check if field type should be expanded (DRAG_SAMPLE, DRAG_MOLECULE or DRAG_ELEMENT)
17
17
  def expandable_field?(field_type)
18
- [Labimotion::FieldType::DRAG_SAMPLE, Labimotion::FieldType::DRAG_MOLECULE].include?(field_type)
18
+ [
19
+ Labimotion::FieldType::DRAG_SAMPLE,
20
+ Labimotion::FieldType::DRAG_MOLECULE,
21
+ Labimotion::FieldType::DRAG_ELEMENT
22
+ ].include?(field_type)
19
23
  end
20
24
 
21
25
  # Generate sanitized filename for export
@@ -34,6 +38,8 @@ module Labimotion
34
38
  case sub_field['type']
35
39
  when Labimotion::FieldType::DRAG_SAMPLE, Labimotion::FieldType::DRAG_MOLECULE
36
40
  format_drag_field_cell(sub_val, sub_field)
41
+ when Labimotion::FieldType::DRAG_ELEMENT
42
+ format_element_cell(sub_val, sub_field)
37
43
  when Labimotion::FieldType::SELECT
38
44
  format_select_cell(sub_val, sub_field)
39
45
  when Labimotion::FieldType::SYSTEM_DEFINED
@@ -95,6 +101,15 @@ module Labimotion
95
101
  end
96
102
  end
97
103
 
104
+ # Format drag_element cell — a linked element has no structure image,
105
+ # so the short label is the cell text
106
+ def format_element_cell(sub_val, sub_field)
107
+ val = sub_val[sub_field['id']]['value'] || {}
108
+ return '' if val.blank?
109
+
110
+ val['el_label'].present? ? val['el_label'].to_s : val['el_name'].to_s
111
+ end
112
+
98
113
  # Format select cell
99
114
  def format_select_cell(sub_val, sub_field)
100
115
  sub_val[sub_field['id']]['value'].to_s