labimotion 2.3.0.rc4 → 2.3.0.rc5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/labimotion/apis/element_variation_api.rb +58 -3
- data/lib/labimotion/libs/element_variation_column_set.rb +426 -0
- data/lib/labimotion/libs/element_variation_header.rb +119 -0
- data/lib/labimotion/libs/export_element_variations.rb +245 -0
- data/lib/labimotion/libs/import_element_variations.rb +411 -0
- data/lib/labimotion/models/concerns/element_fetchable.rb +3 -2
- data/lib/labimotion/models/element.rb +3 -2
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +4 -0
- metadata +19 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 57f6db758edf09de9105830d7a9f9cfda1270ac36bb6ee4351da8ab65b2ea2dc
|
|
4
|
+
data.tar.gz: 0e220ffc2a76645f8e82536cd592d7ee9eecd28e54073b408a602da019839e9f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f3f5ca1afdb69edbbf36f09834a04843d72d9b964db836719991a5d8b1acb10036cf4470f4ca1810bac6b629bc6908dc2ed5054127216ed9d6109b01d205ab07
|
|
7
|
+
data.tar.gz: cf72bfa7a774ef98e1474d5a4e3914304f940105137f18f05adb4b5675afdeab668e305386bda9ce1b842b2083005fc622c210d27c60b1005cfcba90c1eb2617
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# LabIMotion Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
* Features and enhancements:
|
|
5
|
+
* Element variations can be exported to and imported from Excel (`.xlsx`). The exported workbook is itself the import template: `GET /api/v1/element_variations/:element_id/export` and `POST /api/v1/element_variations/:element_id/import`.
|
|
6
|
+
* The exported sheet carries the grid's grouped header -- group, sub-group and field rows, merged as the grid draws them -- above the hidden key and unit rows. Workbooks from the earlier single-row header still import.
|
|
7
|
+
* Import merges rows by uuid, so a sheet only updates the columns it carries; a row with a blank `Row ID` is added as a new variation.
|
|
8
|
+
* `number`, `integer` and `system-defined` columns accept numbers only. A cell holding anything else is cleared with a warning rather than stored as text, which also cleans up text those columns already hold.
|
|
9
|
+
* Import warnings name the cell they came from (`Cell I6: ...`), so the user can go straight to it in the spreadsheet.
|
|
10
|
+
* Bug fixes:
|
|
11
|
+
* `fetch_for_user`: the shared-records branch joined `collection_shares` directly on the element model (an association that only exists on `Collection`), raising `ActiveRecord::ConfigurationError` — rescued into empty search results. Now joins through `collections`. Fixed for both the basic elements (`ElementFetchable`) and the generic element (`Labimotion::Element`), so `search_by_like` and `search_basic_by_like` return shared records again.
|
|
12
|
+
* Dependencies:
|
|
13
|
+
* Added `roo` (~> 2.10) for reading the uploaded workbook.
|
|
14
|
+
|
|
3
15
|
## [2.0.0]
|
|
4
16
|
> 2025-03-25
|
|
5
17
|
* Features and enhancements:
|
|
@@ -1,15 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
3
5
|
module Labimotion
|
|
4
6
|
class ElementVariationAPI < Grape::API
|
|
7
|
+
XLSX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
8
|
+
|
|
5
9
|
rescue_from ActiveRecord::RecordNotFound do
|
|
6
10
|
error!('404 Element not found', 404)
|
|
7
11
|
end
|
|
8
12
|
|
|
13
|
+
helpers do
|
|
14
|
+
def configure_xlsx_response(filename)
|
|
15
|
+
env['api.format'] = :binary
|
|
16
|
+
content_type XLSX_CONTENT_TYPE
|
|
17
|
+
encoded = URI.encode_www_form_component(filename)
|
|
18
|
+
header('Content-Disposition', "attachment; filename=\"#{filename}\"; filename*=UTF-8''#{encoded}")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def uploaded_xlsx_path!(file)
|
|
22
|
+
filename = file[:filename].to_s
|
|
23
|
+
error!('400 Bad Request - expected an .xlsx file', 400) unless filename.downcase.end_with?('.xlsx')
|
|
24
|
+
|
|
25
|
+
tempfile = file[:tempfile]
|
|
26
|
+
error!('400 Bad Request - upload is empty', 400) if tempfile.nil?
|
|
27
|
+
|
|
28
|
+
tempfile.path
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
9
32
|
resource :element_variations do
|
|
10
33
|
params do
|
|
11
34
|
requires :element_id, type: Integer, desc: 'Generic element id'
|
|
12
35
|
end
|
|
36
|
+
# rubocop:disable Metrics/BlockLength
|
|
13
37
|
route_param :element_id do
|
|
14
38
|
before do
|
|
15
39
|
@element = Labimotion::Element.find(params[:element_id])
|
|
@@ -32,14 +56,45 @@ module Labimotion
|
|
|
32
56
|
|
|
33
57
|
record = Labimotion::ElementVariation.find_or_initialize_by(element_id: @element.id)
|
|
34
58
|
record.variations = params[:variations] || {}
|
|
35
|
-
if params.key?(:layout) && record.class.column_names.include?('layout')
|
|
36
|
-
record.layout = params[:layout] || {}
|
|
37
|
-
end
|
|
59
|
+
record.layout = params[:layout] || {} if params.key?(:layout) && record.class.column_names.include?('layout')
|
|
38
60
|
record.save!
|
|
39
61
|
|
|
40
62
|
present record, with: Labimotion::ElementVariationEntity, root: 'element_variation'
|
|
41
63
|
end
|
|
64
|
+
|
|
65
|
+
desc 'Export element variations as an xlsx workbook'
|
|
66
|
+
get :export do
|
|
67
|
+
exporter = Labimotion::ExportElementVariations.new(@element, user: current_user)
|
|
68
|
+
configure_xlsx_response(exporter.filename)
|
|
69
|
+
exporter.read
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
Labimotion.log_exception(e, current_user)
|
|
72
|
+
error!("500 Internal Server Error: #{e.message}", 500)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
desc 'Import element variations from an xlsx workbook produced by the export'
|
|
76
|
+
params do
|
|
77
|
+
requires :file, type: File, desc: 'xlsx file'
|
|
78
|
+
end
|
|
79
|
+
post :import do
|
|
80
|
+
error!('401 Unauthorized', 401) unless ElementPolicy.new(current_user, @element).update?
|
|
81
|
+
|
|
82
|
+
path = uploaded_xlsx_path!(params[:file])
|
|
83
|
+
importer = Labimotion::ImportElementVariations.new(@element, path)
|
|
84
|
+
record = importer.execute!
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
element_variation: Labimotion::ElementVariationEntity.represent(record),
|
|
88
|
+
warnings: importer.warnings
|
|
89
|
+
}
|
|
90
|
+
rescue Labimotion::ImportElementVariations::InvalidWorkbook => e
|
|
91
|
+
error!(e.message, 422)
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
Labimotion.log_exception(e, current_user)
|
|
94
|
+
error!("500 Internal Server Error: #{e.message}", 500)
|
|
95
|
+
end
|
|
42
96
|
end
|
|
97
|
+
# rubocop:enable Metrics/BlockLength
|
|
43
98
|
end
|
|
44
99
|
end
|
|
45
100
|
end
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'labimotion/utils/units'
|
|
4
|
+
|
|
5
|
+
module Labimotion
|
|
6
|
+
## ElementVariationColumnSet
|
|
7
|
+
# Resolves the ordered set of columns for an element's variations grid.
|
|
8
|
+
#
|
|
9
|
+
# Both ExportElementVariations and ImportElementVariations go through this
|
|
10
|
+
# class, so a workbook written by the exporter is always readable by the
|
|
11
|
+
# importer. The ordering mirrors the React grid
|
|
12
|
+
# (GenericElementVariationsColumnDefs.js): driven by the stored layout when
|
|
13
|
+
# there is one, otherwise inferred from the variation data itself -- the same
|
|
14
|
+
# fallback the frontend applies when it loads a record with an empty layout.
|
|
15
|
+
class ElementVariationColumnSet
|
|
16
|
+
UUID_KEY = '__uuid'
|
|
17
|
+
NAME_KEY = '__variation'
|
|
18
|
+
ANALYSES_FIELD = '__analyses__'
|
|
19
|
+
|
|
20
|
+
# Mirrors Labimotion::Prop::LAYERS / ::FIELDS. Held locally because
|
|
21
|
+
# requiring `labimotion/utils/prop` here would race the gem's autoload.
|
|
22
|
+
LAYERS = 'layers'
|
|
23
|
+
FIELDS = 'fields'
|
|
24
|
+
|
|
25
|
+
GROUPS = %w[properties metadata segments].freeze
|
|
26
|
+
GROUP_LABELS = {
|
|
27
|
+
'properties' => 'Properties',
|
|
28
|
+
'metadata' => 'Metadata',
|
|
29
|
+
'segments' => 'Segments'
|
|
30
|
+
}.freeze
|
|
31
|
+
METADATA_FIELDS = %w[notes analyses group].freeze
|
|
32
|
+
METADATA_LABELS = {
|
|
33
|
+
'notes' => 'Notes',
|
|
34
|
+
'analyses' => 'Analyses (IDs)',
|
|
35
|
+
'group' => 'Group'
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Mirrors the field-type allowlist chem-generic-ui applies when it offers
|
|
39
|
+
# columns. `select-multi` deliberately has no Labimotion::FieldType constant.
|
|
40
|
+
COLUMN_FIELD_TYPES = %w[
|
|
41
|
+
integer number select system-defined text date datetime select-multi
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
# `label` is the leaf header only. `group` / `sub_group` carry the two header
|
|
45
|
+
# levels above it, so the exporter can rebuild the grid's banded header.
|
|
46
|
+
# `sub_group_key` disambiguates two sub-groups that happen to share a label.
|
|
47
|
+
Column = Struct.new(
|
|
48
|
+
:key, :label, :unit, :kind, :field_type, :group, :sub_group, :sub_group_key,
|
|
49
|
+
keyword_init: true
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
def build(element, variation, segment_klasses: nil)
|
|
54
|
+
new(element, variation, segment_klasses: segment_klasses).columns
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def encode_property_key(layer_key, field_key)
|
|
58
|
+
"layer#{layer_key}field#{field_key}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def decode_property_key(key)
|
|
62
|
+
match = key.to_s.match(/\Alayer(.+?)field(.+)\z/m)
|
|
63
|
+
return nil unless match
|
|
64
|
+
|
|
65
|
+
{ layer_key: match[1], field_key: match[2] }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def analyses_property_key?(key)
|
|
69
|
+
decoded = decode_property_key(key)
|
|
70
|
+
!decoded.nil? && decoded[:field_key] == ANALYSES_FIELD
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def initialize(element, variation, segment_klasses: nil)
|
|
75
|
+
@element = element
|
|
76
|
+
@variations = variation.respond_to?(:variations_hash) ? variation.variations_hash : {}
|
|
77
|
+
@layout = variation.respond_to?(:layout_hash) ? variation.layout_hash : {}
|
|
78
|
+
@segment_klasses = segment_klasses
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
attr_reader :element, :variations, :layout
|
|
82
|
+
|
|
83
|
+
def columns
|
|
84
|
+
@columns ||= begin
|
|
85
|
+
cols = [
|
|
86
|
+
Column.new(key: UUID_KEY, label: 'Row ID', unit: '', kind: :uuid, field_type: nil),
|
|
87
|
+
Column.new(key: NAME_KEY, label: 'Variation', unit: '', kind: :name, field_type: nil)
|
|
88
|
+
]
|
|
89
|
+
group_order.each { |group| cols.concat(columns_for_group(group)) }
|
|
90
|
+
cols
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def lookup(key)
|
|
95
|
+
@lookup ||= columns.each_with_object({}) { |col, acc| acc[col.key] = col }
|
|
96
|
+
@lookup[key]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Variation rows in the grid's display order: layout rowOrder first, then
|
|
100
|
+
# anything the layout does not mention, uuid-sorted (as `sortedRows` does).
|
|
101
|
+
def rows
|
|
102
|
+
seen = {}
|
|
103
|
+
ordered = []
|
|
104
|
+
Array(layout['rowOrder']).each do |uuid|
|
|
105
|
+
row = variations[uuid.to_s]
|
|
106
|
+
next unless row.is_a?(Hash)
|
|
107
|
+
|
|
108
|
+
ordered << row
|
|
109
|
+
seen[uuid.to_s] = true
|
|
110
|
+
end
|
|
111
|
+
remaining = variations.reject { |uuid, row| seen[uuid.to_s] || !row.is_a?(Hash) }
|
|
112
|
+
ordered + remaining.values.sort_by { |row| row['uuid'].to_s }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def segment_klasses
|
|
116
|
+
@segment_klasses ||= Labimotion::SegmentKlass.where(element_klass_id: element.element_klass_id).to_a
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def segment_klass(klass_id)
|
|
120
|
+
@segment_klass_index ||= segment_klasses.each_with_object({}) do |klass, acc|
|
|
121
|
+
acc[klass.id.to_s] = klass
|
|
122
|
+
end
|
|
123
|
+
@segment_klass_index[klass_id.to_s]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def columns_for_group(group)
|
|
129
|
+
case group
|
|
130
|
+
when 'properties' then property_columns
|
|
131
|
+
when 'metadata' then metadata_columns
|
|
132
|
+
when 'segments' then segment_columns
|
|
133
|
+
else []
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def group_order
|
|
138
|
+
stored = Array(layout['groupOrder']).select { |group| GROUPS.include?(group) }
|
|
139
|
+
stored + (GROUPS - stored)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# ---- properties -------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
# Grouped by layer, each layer's fields followed by its "Link Analyses"
|
|
145
|
+
# column -- the leaf order buildColumnDefs produces.
|
|
146
|
+
def property_columns
|
|
147
|
+
analysis_layers = selected_analysis_layers
|
|
148
|
+
property_layers.flat_map do |layer_key, layer|
|
|
149
|
+
next layer[:columns] unless analysis_layers.include?(layer_key)
|
|
150
|
+
|
|
151
|
+
layer[:columns] + [analyses_layer_column(layer_key, layer[:label])]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# layer_key => { label:, columns: }, in the grid's layer order: layers
|
|
156
|
+
# holding a selected field first, then layers selected for analyses only.
|
|
157
|
+
def property_layers
|
|
158
|
+
layers = {}
|
|
159
|
+
selected_property_keys.each do |property_key|
|
|
160
|
+
option = property_options[property_key]
|
|
161
|
+
next if option.nil?
|
|
162
|
+
|
|
163
|
+
layer = ensure_property_layer(layers, option['layerKey'], option['layerLabel'])
|
|
164
|
+
layer[:columns] << property_column(property_key, option, layer[:label])
|
|
165
|
+
end
|
|
166
|
+
selected_analysis_layers.each do |layer_key|
|
|
167
|
+
ensure_property_layer(layers, layer_key, layer_labels[layer_key.to_s])
|
|
168
|
+
end
|
|
169
|
+
layers
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def ensure_property_layer(layers, layer_key, layer_label)
|
|
173
|
+
key = layer_key.to_s
|
|
174
|
+
layers[key] ||= { label: presence(layer_label) || key, columns: [] }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def property_column(property_key, option, layer_label)
|
|
178
|
+
unit = unit_for("prop:#{property_key}", option, inferred_property_unit(property_key))
|
|
179
|
+
Column.new(
|
|
180
|
+
key: "prop:#{property_key}",
|
|
181
|
+
label: with_unit(option['fieldLabel'], unit),
|
|
182
|
+
unit: unit,
|
|
183
|
+
kind: :property,
|
|
184
|
+
field_type: option['type'],
|
|
185
|
+
group: GROUP_LABELS['properties'],
|
|
186
|
+
sub_group: layer_label,
|
|
187
|
+
sub_group_key: option['layerKey'].to_s
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def analyses_layer_column(layer_key, layer_label)
|
|
192
|
+
Column.new(
|
|
193
|
+
key: "prop:#{self.class.encode_property_key(layer_key, ANALYSES_FIELD)}",
|
|
194
|
+
label: 'Link Analyses (IDs)',
|
|
195
|
+
unit: '',
|
|
196
|
+
kind: :property,
|
|
197
|
+
field_type: ANALYSES_FIELD,
|
|
198
|
+
group: GROUP_LABELS['properties'],
|
|
199
|
+
sub_group: layer_label,
|
|
200
|
+
sub_group_key: layer_key.to_s
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def selected_property_keys
|
|
205
|
+
stored = Array(layout['selectedPropertyKeys']).map(&:to_s)
|
|
206
|
+
return stored unless stored.empty?
|
|
207
|
+
|
|
208
|
+
keys_from_data.reject { |key| self.class.analyses_property_key?(key) }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def selected_analysis_layers
|
|
212
|
+
stored = Array(layout['selectedAnalysisLayers']).map(&:to_s)
|
|
213
|
+
return stored unless stored.empty?
|
|
214
|
+
|
|
215
|
+
keys_from_data.filter_map do |key|
|
|
216
|
+
decoded = self.class.decode_property_key(key)
|
|
217
|
+
decoded[:layer_key] if decoded && decoded[:field_key] == ANALYSES_FIELD
|
|
218
|
+
end.uniq
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def keys_from_data
|
|
222
|
+
@keys_from_data ||= variations.values.flat_map do |row|
|
|
223
|
+
row.is_a?(Hash) ? (row['properties'] || {}).keys : []
|
|
224
|
+
end.uniq
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def inferred_property_unit(key)
|
|
228
|
+
variations.each_value do |row|
|
|
229
|
+
next unless row.is_a?(Hash)
|
|
230
|
+
|
|
231
|
+
cell = (row['properties'] || {})[key]
|
|
232
|
+
return cell['unit'].to_s if cell.is_a?(Hash) && !cell['unit'].to_s.empty?
|
|
233
|
+
end
|
|
234
|
+
nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# ---- metadata ---------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
# Metadata leaves hang straight off the group, as they do in the grid: no
|
|
240
|
+
# sub_group, so the exporter spans their header down a row.
|
|
241
|
+
def metadata_columns
|
|
242
|
+
selected_metadata_keys.map do |field|
|
|
243
|
+
Column.new(
|
|
244
|
+
key: "meta:#{field}",
|
|
245
|
+
label: METADATA_LABELS[field] || field,
|
|
246
|
+
unit: '',
|
|
247
|
+
kind: :metadata,
|
|
248
|
+
field_type: field,
|
|
249
|
+
group: GROUP_LABELS['metadata']
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def selected_metadata_keys
|
|
255
|
+
stored = Array(layout['selectedMetadataKeys']).map(&:to_s)
|
|
256
|
+
stored.empty? ? METADATA_FIELDS.dup : stored
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# ---- segments ---------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def segment_columns
|
|
262
|
+
selected_segment_ids.flat_map do |klass_id|
|
|
263
|
+
klass = segment_klass(klass_id)
|
|
264
|
+
segment_label = klass ? (klass.label || "Segment #{klass_id}") : "Segment #{klass_id}"
|
|
265
|
+
options = segment_field_options(klass_id)
|
|
266
|
+
|
|
267
|
+
selected_segment_fields(klass_id).filter_map do |field_key|
|
|
268
|
+
option = options[field_key]
|
|
269
|
+
next if option.nil?
|
|
270
|
+
|
|
271
|
+
unit = unit_for("seg:#{klass_id}:#{field_key}", option, nil)
|
|
272
|
+
Column.new(
|
|
273
|
+
key: "seg:#{klass_id}:#{field_key}",
|
|
274
|
+
label: with_unit(option['fieldLabel'], unit),
|
|
275
|
+
unit: unit,
|
|
276
|
+
kind: :segment,
|
|
277
|
+
field_type: option['type'],
|
|
278
|
+
group: GROUP_LABELS['segments'],
|
|
279
|
+
sub_group: segment_label,
|
|
280
|
+
sub_group_key: klass_id.to_s
|
|
281
|
+
)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def selected_segment_ids
|
|
287
|
+
stored = Array(layout['selectedSegmentIds']).map(&:to_s)
|
|
288
|
+
return stored unless stored.empty?
|
|
289
|
+
|
|
290
|
+
variations.values.flat_map do |row|
|
|
291
|
+
row.is_a?(Hash) ? (row['segments'] || {}).keys.map(&:to_s) : []
|
|
292
|
+
end.uniq
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def selected_segment_fields(klass_id)
|
|
296
|
+
stored = layout['selectedSegmentFields']
|
|
297
|
+
if stored.is_a?(Hash)
|
|
298
|
+
keys = stored[klass_id.to_s] || stored[klass_id.to_i]
|
|
299
|
+
return Array(keys).map(&:to_s) unless keys.nil?
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
variations.values.flat_map do |row|
|
|
303
|
+
next [] unless row.is_a?(Hash)
|
|
304
|
+
|
|
305
|
+
(row.dig('segments', klass_id.to_s) || {}).keys.map(&:to_s)
|
|
306
|
+
end.uniq
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def segment_field_options(klass_id)
|
|
310
|
+
@segment_field_options ||= {}
|
|
311
|
+
@segment_field_options[klass_id.to_s] ||= begin
|
|
312
|
+
klass = segment_klass(klass_id)
|
|
313
|
+
options = {}
|
|
314
|
+
each_layer_field(release_of(klass)) do |_layer_key, _layer_label, field|
|
|
315
|
+
options[field['field'].to_s] ||= field_option(field)
|
|
316
|
+
end
|
|
317
|
+
options
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# ---- shared schema walking -------------------------------------------
|
|
322
|
+
|
|
323
|
+
def property_options
|
|
324
|
+
@property_options ||= begin
|
|
325
|
+
options = {}
|
|
326
|
+
each_layer_field(element_klass_release) do |layer_key, layer_label, field|
|
|
327
|
+
key = self.class.encode_property_key(layer_key, field['field'])
|
|
328
|
+
options[key] = field_option(field).merge('layerKey' => layer_key, 'layerLabel' => layer_label)
|
|
329
|
+
end
|
|
330
|
+
options
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def layer_labels
|
|
335
|
+
@layer_labels ||= begin
|
|
336
|
+
layers = element_klass_release[LAYERS] || {}
|
|
337
|
+
layers.each_with_object({}) do |(layer_key, layer), acc|
|
|
338
|
+
acc[layer_key.to_s] = layer['label'] || layer_key.to_s
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def element_klass_release
|
|
344
|
+
@element_klass_release ||= begin
|
|
345
|
+
klass = element.respond_to?(:element_klass) ? element.element_klass : nil
|
|
346
|
+
release = release_of(klass)
|
|
347
|
+
release.empty? ? release_of(element) : release
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def release_of(record)
|
|
352
|
+
return {} if record.nil? || !record.respond_to?(:properties_release)
|
|
353
|
+
|
|
354
|
+
release = record.properties_release
|
|
355
|
+
release.is_a?(Hash) ? release : {}
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def each_layer_field(release)
|
|
359
|
+
layers = release[LAYERS] || {}
|
|
360
|
+
layers.each do |layer_key, layer|
|
|
361
|
+
next unless layer.is_a?(Hash)
|
|
362
|
+
|
|
363
|
+
layer_label = layer['label'] || layer_key.to_s
|
|
364
|
+
Array(layer[FIELDS]).each do |field|
|
|
365
|
+
next unless field.is_a?(Hash) && !field['field'].to_s.empty?
|
|
366
|
+
next unless COLUMN_FIELD_TYPES.include?(field['type'].to_s)
|
|
367
|
+
|
|
368
|
+
yield(layer_key.to_s, layer_label, field)
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def field_option(field)
|
|
374
|
+
label = field['label'].to_s.empty? ? field['field'].to_s : field['label'].to_s
|
|
375
|
+
{
|
|
376
|
+
'fieldKey' => field['field'].to_s,
|
|
377
|
+
'fieldLabel' => label,
|
|
378
|
+
'type' => field['type'].to_s,
|
|
379
|
+
'defaultUnit' => default_unit(field)
|
|
380
|
+
}
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# ---- units ------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
# The grid stores unit *keys* (`C`, `mm_s2`), never the display labels --
|
|
386
|
+
# some of which carry HTML (`mm/s<sup>2</sup>`). Keep the key: it is what a
|
|
387
|
+
# round-trip must preserve.
|
|
388
|
+
def field_units(field)
|
|
389
|
+
quantity = field['option_layers'] || field['generic_quantity']
|
|
390
|
+
return [] if quantity.to_s.empty?
|
|
391
|
+
|
|
392
|
+
config = Labimotion::Units::FIELDS.find { |entry| entry[:field] == quantity }
|
|
393
|
+
return [] unless config
|
|
394
|
+
|
|
395
|
+
Array(config[:units]).filter_map { |unit| unit[:key]&.to_s }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def default_unit(field)
|
|
399
|
+
units = field_units(field)
|
|
400
|
+
return '' if units.empty?
|
|
401
|
+
|
|
402
|
+
value_system = field['value_system'].to_s
|
|
403
|
+
value_system.empty? ? units.first : value_system
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def unit_for(column_key, option, inferred)
|
|
407
|
+
stored = column_units[column_key]
|
|
408
|
+
return stored.to_s unless stored.to_s.empty?
|
|
409
|
+
return inferred.to_s unless inferred.to_s.empty?
|
|
410
|
+
|
|
411
|
+
option['defaultUnit'].to_s
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def column_units
|
|
415
|
+
@column_units ||= layout['columnUnits'].is_a?(Hash) ? layout['columnUnits'] : {}
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def with_unit(label, unit)
|
|
419
|
+
unit.to_s.empty? ? label : "#{label} [#{unit}]"
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def presence(value)
|
|
423
|
+
value.to_s.empty? ? nil : value.to_s
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'caxlsx'
|
|
4
|
+
|
|
5
|
+
module Labimotion
|
|
6
|
+
## ElementVariationHeader
|
|
7
|
+
# Turns an ordered column set into the banded header the React grid draws:
|
|
8
|
+
#
|
|
9
|
+
# row 1 group Properties | Metadata | Segments
|
|
10
|
+
# row 2 sub-group Conditions | Notes | Group | My Segment
|
|
11
|
+
# row 3 leaf Temperature [C] | Solvent | | | Mass [g]
|
|
12
|
+
#
|
|
13
|
+
# Adjacent columns sharing a header are merged across, and a header with no
|
|
14
|
+
# level beneath it -- the identity columns, and the metadata leaves, which hang
|
|
15
|
+
# straight off their group -- is merged down to the leaf row. That is exactly
|
|
16
|
+
# what ag-grid renders for the same columns.
|
|
17
|
+
#
|
|
18
|
+
# `rows` are the three cell arrays; `merges` the ranges to hand to Axlsx.
|
|
19
|
+
class ElementVariationHeader
|
|
20
|
+
ROWS = 3
|
|
21
|
+
|
|
22
|
+
def initialize(columns)
|
|
23
|
+
@columns = columns
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :columns
|
|
27
|
+
|
|
28
|
+
def rows
|
|
29
|
+
plan[:rows]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def merges
|
|
33
|
+
plan[:merges]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def plan
|
|
39
|
+
@plan ||= build
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build
|
|
43
|
+
@rows = Array.new(ROWS) { Array.new(columns.length, '') }
|
|
44
|
+
@merges = []
|
|
45
|
+
|
|
46
|
+
runs(columns.map(&:group)).each { |run| place_group(run) }
|
|
47
|
+
runs(columns.map { |column| sub_group_key(column) }).each { |run| place_sub_group(run) }
|
|
48
|
+
|
|
49
|
+
{ rows: @rows, merges: @merges }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def place_group(run)
|
|
53
|
+
# No group at all: an identity column, spanning the whole header.
|
|
54
|
+
return span_down(run, 0) if blank?(run[:key])
|
|
55
|
+
|
|
56
|
+
span_across(run, 0, run[:key])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def place_sub_group(run)
|
|
60
|
+
return if run[:key].nil?
|
|
61
|
+
|
|
62
|
+
sub_group = columns[run[:first]].sub_group
|
|
63
|
+
# No sub-group: a metadata leaf, spanning the rows below its group.
|
|
64
|
+
return span_down(run, 1) if blank?(sub_group)
|
|
65
|
+
|
|
66
|
+
span_across(run, 1, sub_group)
|
|
67
|
+
fill(run, 2)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# One cell per column, each merged down to the leaf row.
|
|
71
|
+
def span_down(run, row)
|
|
72
|
+
fill(run, row)
|
|
73
|
+
column_range(run).each { |index| merge(index, row, index, ROWS - 1) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A single cell carrying `text` across the whole run.
|
|
77
|
+
def span_across(run, row, text)
|
|
78
|
+
@rows[row][run[:first]] = text
|
|
79
|
+
merge(run[:first], row, run[:last], row) unless run[:last] == run[:first]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def fill(run, row)
|
|
83
|
+
column_range(run).each { |index| @rows[row][index] = columns[index].label }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def column_range(run)
|
|
87
|
+
(run[:first]..run[:last])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def merge(from_column, from_row, to_column, to_row)
|
|
91
|
+
@merges << [cell_ref(from_column, from_row), cell_ref(to_column, to_row)]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cell_ref(column_index, row_index)
|
|
95
|
+
"#{Axlsx.col_ref(column_index)}#{row_index + 1}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Keyed on the sub-group *key*, not its label: two layers may share a label.
|
|
99
|
+
def sub_group_key(column)
|
|
100
|
+
blank?(column.group) ? nil : [column.group, column.sub_group_key]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Contiguous columns sharing a header key.
|
|
104
|
+
def runs(keys)
|
|
105
|
+
keys.each_with_index.with_object([]) do |(key, index), acc|
|
|
106
|
+
last = acc.last
|
|
107
|
+
if last && last[:key] == key
|
|
108
|
+
last[:last] = index
|
|
109
|
+
else
|
|
110
|
+
acc << { key: key, first: index, last: index }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def blank?(value)
|
|
116
|
+
value.to_s.empty?
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|