trevl 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/NOTICE +9 -0
- data/README.md +431 -0
- data/Rakefile +10 -0
- data/assets/trendence-logo.svg +17 -0
- data/assets/trevl-logo.svg +20 -0
- data/docs/screenshots/01_bar_chart.png +0 -0
- data/docs/screenshots/02_column_chart.png +0 -0
- data/docs/screenshots/03_line_chart.png +0 -0
- data/docs/screenshots/04_pie_chart.png +0 -0
- data/docs/screenshots/05_computed_fields.png +0 -0
- data/docs/screenshots/06_postprocess_top_n.png +0 -0
- data/docs/screenshots/10_multi_series.png +0 -0
- data/examples/01_bar_chart.yml +16 -0
- data/examples/02_column_chart.yml +19 -0
- data/examples/03_line_chart.yml +21 -0
- data/examples/04_pie_chart.yml +15 -0
- data/examples/05_computed_fields.yml +26 -0
- data/examples/06_postprocess_top_n.yml +21 -0
- data/examples/07_score_kpi.yml +13 -0
- data/examples/08_table.yml +14 -0
- data/examples/09_template_inheritance.yml +25 -0
- data/examples/10_multi_series.yml +23 -0
- data/lib/trevl/auth/bearer_token.rb +23 -0
- data/lib/trevl/configuration.rb +24 -0
- data/lib/trevl/core_ext/hash.rb +37 -0
- data/lib/trevl/data_source/api.rb +86 -0
- data/lib/trevl/data_source/base.rb +19 -0
- data/lib/trevl/data_source/cube.rb +70 -0
- data/lib/trevl/data_source/static.rb +30 -0
- data/lib/trevl/data_source.rb +38 -0
- data/lib/trevl/errors.rb +9 -0
- data/lib/trevl/highcharts_asset.rb +45 -0
- data/lib/trevl/html_renderer.rb +91 -0
- data/lib/trevl/notebook/display.rb +53 -0
- data/lib/trevl/notebook.rb +41 -0
- data/lib/trevl/processor.rb +106 -0
- data/lib/trevl/renderer/data_fetcher.rb +63 -0
- data/lib/trevl/renderer/ref_parser.rb +61 -0
- data/lib/trevl/renderer/transform.rb +104 -0
- data/lib/trevl/renderer.rb +345 -0
- data/lib/trevl/schema/component.json +337 -0
- data/lib/trevl/schema/document.json +41 -0
- data/lib/trevl/template_store.rb +38 -0
- data/lib/trevl/validator.rb +96 -0
- data/lib/trevl/version.rb +5 -0
- data/lib/trevl.rb +99 -0
- data/llms.txt +171 -0
- data/notebooks/demo.ipynb +312 -0
- data/notebooks/exploration.ipynb +164 -0
- data/scripts/generate_screenshots.rb +107 -0
- metadata +137 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trevl
|
|
4
|
+
class Processor
|
|
5
|
+
attr_reader :trevl_content, :processed_content, :parse_errors
|
|
6
|
+
|
|
7
|
+
def initialize(trevl_content)
|
|
8
|
+
@trevl_content = trevl_content
|
|
9
|
+
@parse_errors = []
|
|
10
|
+
@processed_content = process_content
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.from_hash(trevl_hash)
|
|
14
|
+
new(trevl_hash.to_yaml)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def components
|
|
18
|
+
@processed_content["components"]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def component_yaml_content
|
|
22
|
+
comps = @processed_content["components"]
|
|
23
|
+
stringified = if comps.is_a?(Array)
|
|
24
|
+
comps.map { |c| c.is_a?(Hash) ? CoreExt::Hash.deep_stringify_keys(c) : c }
|
|
25
|
+
else
|
|
26
|
+
comps
|
|
27
|
+
end
|
|
28
|
+
YAML.dump(stringified, canonical: false)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def full_yaml_content
|
|
32
|
+
YAML.dump(CoreExt::Hash.deep_stringify_keys(@processed_content), canonical: false)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def valid?
|
|
36
|
+
@parse_errors.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def process_content
|
|
42
|
+
yaml_content = begin
|
|
43
|
+
YAML.safe_load(trevl_content, permitted_classes: [Hash, Symbol], aliases: true)
|
|
44
|
+
rescue Psych::SyntaxError => e
|
|
45
|
+
@parse_errors << "Error parsing TREVL YAML: #{e.message}"
|
|
46
|
+
raise ParseError, "Error parsing TREVL YAML: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
unless yaml_content.is_a?(Hash) || yaml_content.is_a?(Array)
|
|
50
|
+
@parse_errors << "TREVL content must be a Hash or Array"
|
|
51
|
+
raise ParseError, "TREVL content must be a Hash or Array"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Support both top-level hash with "components" key and bare array of components
|
|
55
|
+
if yaml_content.is_a?(Array)
|
|
56
|
+
yaml_content = {"components" => yaml_content}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if yaml_content["components"].is_a?(Array)
|
|
60
|
+
yaml_content["components"] = apply_templates(yaml_content["components"])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
yaml_content
|
|
64
|
+
rescue ParseError
|
|
65
|
+
raise
|
|
66
|
+
rescue => e
|
|
67
|
+
@parse_errors << "Error processing TREVL content: #{e.message}"
|
|
68
|
+
raise ParseError, "Error processing TREVL content: #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def apply_templates(components)
|
|
72
|
+
components.map { |component| apply_template_to_component(component) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def apply_template_to_component(component)
|
|
76
|
+
return component unless component.is_a?(Hash) && component["template"]
|
|
77
|
+
|
|
78
|
+
template_name = component["template"]
|
|
79
|
+
template_hash = Trevl.template_store.find(template_name)
|
|
80
|
+
return component unless template_hash
|
|
81
|
+
|
|
82
|
+
deep_merge_with_overwrite(template_hash, component.except("template"))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def deep_merge_with_overwrite(template_hash, component_hash)
|
|
86
|
+
return component_hash unless template_hash.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
result = {}
|
|
89
|
+
|
|
90
|
+
component_hash.each do |key, component_val|
|
|
91
|
+
result[key] = if template_hash.key?(key) && template_hash[key].is_a?(Hash) && component_val.is_a?(Hash)
|
|
92
|
+
deep_merge_with_overwrite(template_hash[key], component_val)
|
|
93
|
+
else
|
|
94
|
+
component_val
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
template_hash.each do |key, template_val|
|
|
99
|
+
next if component_hash.key?(key)
|
|
100
|
+
result[key] = template_val
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
result
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trevl
|
|
4
|
+
class Renderer
|
|
5
|
+
# Normalizes API responses and resolves API parameters.
|
|
6
|
+
#
|
|
7
|
+
# fetcher = DataFetcher.new
|
|
8
|
+
# fetcher.coerce_payload([{x: 1}])
|
|
9
|
+
# # => {"data" => [{"x" => 1}], "meta" => {}}
|
|
10
|
+
#
|
|
11
|
+
class DataFetcher
|
|
12
|
+
def coerce_payload(result)
|
|
13
|
+
case result
|
|
14
|
+
when Array
|
|
15
|
+
{"data" => result, "meta" => {}}
|
|
16
|
+
when Hash
|
|
17
|
+
data = result["data"] || result[:data]
|
|
18
|
+
meta = result["meta"] || result[:meta] || {}
|
|
19
|
+
meta = {} unless meta.is_a?(Hash)
|
|
20
|
+
if data.is_a?(Array)
|
|
21
|
+
{"data" => data, "meta" => CoreExt::Hash.deep_stringify_keys(meta)}
|
|
22
|
+
else
|
|
23
|
+
{"data" => [], "meta" => CoreExt::Hash.deep_stringify_keys(meta)}
|
|
24
|
+
end
|
|
25
|
+
else
|
|
26
|
+
{"data" => [], "meta" => {}}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def merge_api_params(component, query_params)
|
|
31
|
+
api_params = component["api_parameters"] || component["api-parameters"] || {}
|
|
32
|
+
api_params = CoreExt::Hash.deep_stringify_keys(api_params) if api_params.is_a?(Hash)
|
|
33
|
+
api_params = resolve_placeholders(api_params, query_params)
|
|
34
|
+
|
|
35
|
+
overrides = query_params["param_override"] || query_params[:param_override]
|
|
36
|
+
if overrides.is_a?(Hash)
|
|
37
|
+
overrides = CoreExt::Hash.deep_stringify_keys(overrides)
|
|
38
|
+
api_params = CoreExt::Hash.deep_merge(api_params, overrides)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
api_params
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def resolve_placeholders(params, query_params)
|
|
47
|
+
params.each_with_object({}) do |(key, value), result|
|
|
48
|
+
if value.is_a?(String) && value.start_with?("$")
|
|
49
|
+
param_key = value.delete_prefix("$")
|
|
50
|
+
raw = query_params[param_key] || query_params[param_key.to_sym]
|
|
51
|
+
resolved = raw.is_a?(Array) ? raw.first : raw
|
|
52
|
+
result[key] = resolved unless resolved.nil?
|
|
53
|
+
elsif value.is_a?(Hash)
|
|
54
|
+
resolved = resolve_placeholders(value, query_params)
|
|
55
|
+
result[key] = resolved unless resolved.empty?
|
|
56
|
+
else
|
|
57
|
+
result[key] = value
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trevl
|
|
4
|
+
class Renderer
|
|
5
|
+
# Parses $-prefixed variable references in TREVL YAML.
|
|
6
|
+
#
|
|
7
|
+
# parser = RefParser.new
|
|
8
|
+
# parser.parse("salary.data.q50")
|
|
9
|
+
# # => {resource: nil, endpoint: "salary", scope: :data, keys: ["q50"]}
|
|
10
|
+
#
|
|
11
|
+
class RefParser
|
|
12
|
+
def parse(ref_without_dollar)
|
|
13
|
+
parts = ref_without_dollar.to_s.split(".")
|
|
14
|
+
resource = nil
|
|
15
|
+
|
|
16
|
+
if parts.length >= 4 && %w[data meta].include?(parts[2])
|
|
17
|
+
resource = parts.shift
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
endpoint = parts[0].to_s
|
|
21
|
+
return {resource: resource, endpoint: endpoint, scope: :data, keys: [], legacy: true} if parts.length < 2
|
|
22
|
+
|
|
23
|
+
case parts[1]
|
|
24
|
+
when "data"
|
|
25
|
+
{resource: resource, endpoint: endpoint, scope: :data, keys: parts[2..] || [], legacy: false}
|
|
26
|
+
when "meta"
|
|
27
|
+
{resource: resource, endpoint: endpoint, scope: :meta, keys: parts[2..] || [], legacy: false}
|
|
28
|
+
else
|
|
29
|
+
{resource: resource, endpoint: endpoint, scope: :data, keys: [parts.last], legacy: true}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ref_key(parsed)
|
|
34
|
+
[parsed[:resource], parsed[:endpoint]].compact.join("/")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def ref_key_for(ref_without_dollar)
|
|
38
|
+
ref_key(parse(ref_without_dollar))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def extract_references(obj)
|
|
42
|
+
refs = []
|
|
43
|
+
case obj
|
|
44
|
+
when Hash then obj.each_value { |v| refs.concat(extract_references(v)) }
|
|
45
|
+
when Array then obj.each { |v| refs.concat(extract_references(v)) }
|
|
46
|
+
when String then refs << obj.delete_prefix("$") if obj.start_with?("$")
|
|
47
|
+
end
|
|
48
|
+
refs.uniq
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def dig(obj, keys)
|
|
52
|
+
return obj if keys.nil? || keys.empty?
|
|
53
|
+
keys.reduce(obj) do |acc, key|
|
|
54
|
+
return nil if acc.nil?
|
|
55
|
+
next nil unless acc.is_a?(Hash)
|
|
56
|
+
acc[key.to_s] || acc[key.to_sym]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "execjs"
|
|
4
|
+
|
|
5
|
+
module Trevl
|
|
6
|
+
class Renderer
|
|
7
|
+
# Applies computed fields and postprocess transformations to data rows.
|
|
8
|
+
#
|
|
9
|
+
# transform = Transform.new(ref_parser)
|
|
10
|
+
# transform.apply_computed_fields(rows, payloads, component)
|
|
11
|
+
#
|
|
12
|
+
class Transform
|
|
13
|
+
def initialize(ref_parser)
|
|
14
|
+
@refs = ref_parser
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Applies computed fields (per-row JavaScript expressions) to all rows.
|
|
18
|
+
def apply_computed_fields(rows_by_ref_key, payload_by_ref_key, component)
|
|
19
|
+
computeds = collect_computed_fields(component)
|
|
20
|
+
return rows_by_ref_key unless computeds.any?
|
|
21
|
+
|
|
22
|
+
rows_by_ref_key.each do |rk, rows|
|
|
23
|
+
rows.each do |row|
|
|
24
|
+
computeds.each do |computed|
|
|
25
|
+
arg_names, arg_values = resolve_arguments(computed, row, rk, payload_by_ref_key)
|
|
26
|
+
js = "(function(#{arg_names.join(", ")}) { return #{computed["code"]} })(#{arg_values.map(&:to_json).join(", ")})"
|
|
27
|
+
row[computed["name"]] = ExecJS.eval(js)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
rows_by_ref_key
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Applies postprocess JavaScript to the full dataset.
|
|
36
|
+
def apply_postprocess(rows_by_ref_key, component, warnings)
|
|
37
|
+
code = component["postprocess"]
|
|
38
|
+
return rows_by_ref_key unless code.is_a?(String) && !code.strip.empty?
|
|
39
|
+
|
|
40
|
+
rows_by_ref_key.each do |rk, rows|
|
|
41
|
+
js = "(function() { var $result = #{rows.to_json}; #{code.strip.chomp(";")}; return $result; })()"
|
|
42
|
+
begin
|
|
43
|
+
result = ExecJS.eval(js)
|
|
44
|
+
if result.is_a?(Array)
|
|
45
|
+
rows_by_ref_key[rk] = result
|
|
46
|
+
else
|
|
47
|
+
warnings << "Postprocess for '#{component["id"]}' returned #{result.class} instead of Array"
|
|
48
|
+
Trevl.logger.warn("[Trevl::Renderer] Postprocess returned non-Array for '#{component["id"]}'")
|
|
49
|
+
end
|
|
50
|
+
rescue ExecJS::Error => e
|
|
51
|
+
raise RenderError, "Postprocess JavaScript error for '#{component["id"]}': #{e.message}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
rows_by_ref_key
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def collect_computed_fields(component)
|
|
61
|
+
computeds = Array(component["computed"])
|
|
62
|
+
|
|
63
|
+
if component["queries"].is_a?(Array)
|
|
64
|
+
existing_names = computeds.map { |c| c["name"] }.compact
|
|
65
|
+
component["queries"].each do |q|
|
|
66
|
+
Array(q["computed"]).each do |c|
|
|
67
|
+
computeds << c unless existing_names.include?(c["name"])
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
computeds
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resolve_arguments(computed, row, rk, payload_by_ref_key)
|
|
76
|
+
args = computed["arguments"] || {}
|
|
77
|
+
arg_names = []
|
|
78
|
+
arg_values = []
|
|
79
|
+
|
|
80
|
+
args.each do |name, ref|
|
|
81
|
+
arg_names << name
|
|
82
|
+
if ref.is_a?(String) && ref.start_with?("$")
|
|
83
|
+
p = @refs.parse(ref.delete_prefix("$"))
|
|
84
|
+
p_key = @refs.ref_key(p)
|
|
85
|
+
ep_meta = (payload_by_ref_key[p_key] || {})["meta"] || {}
|
|
86
|
+
val = if p[:scope] == :meta
|
|
87
|
+
@refs.dig(ep_meta, p[:keys])
|
|
88
|
+
elsif p_key == rk
|
|
89
|
+
@refs.dig(row, p[:keys])
|
|
90
|
+
else
|
|
91
|
+
first = payload_by_ref_key[p_key]&.dig("data")&.first
|
|
92
|
+
@refs.dig(first, p[:keys])
|
|
93
|
+
end
|
|
94
|
+
arg_values << val
|
|
95
|
+
else
|
|
96
|
+
arg_values << ref
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
[arg_names, arg_values]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "renderer/ref_parser"
|
|
4
|
+
require_relative "renderer/data_fetcher"
|
|
5
|
+
require_relative "renderer/transform"
|
|
6
|
+
|
|
7
|
+
module Trevl
|
|
8
|
+
class Renderer
|
|
9
|
+
attr_reader :component, :query_params, :data_sources, :inline_source, :raw_data, :warnings
|
|
10
|
+
|
|
11
|
+
def initialize(component_hash, query_params = {}, data_sources: {}, data: nil)
|
|
12
|
+
@component = component_hash
|
|
13
|
+
@query_params = query_params || {}
|
|
14
|
+
@data_sources = normalize_data_sources(data_sources)
|
|
15
|
+
@inline_source = data ? DataSource::Static.new(data: data) : nil
|
|
16
|
+
@raw_data = {}
|
|
17
|
+
@warnings = []
|
|
18
|
+
@refs = RefParser.new
|
|
19
|
+
@fetcher = DataFetcher.new
|
|
20
|
+
@transform = Transform.new(@refs)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def render
|
|
24
|
+
case component["type"]
|
|
25
|
+
when "text" then render_text
|
|
26
|
+
when "filter" then has_api? ? render_filter : nil
|
|
27
|
+
when "table" then has_api? ? render_table : nil
|
|
28
|
+
when "score" then has_api? ? render_score : nil
|
|
29
|
+
when "chart" then has_api? ? render_chart : nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Public class methods kept for backward compatibility and testing
|
|
34
|
+
def self.parse_trevl_ref(ref) = RefParser.new.parse(ref)
|
|
35
|
+
def self.coerce_fetch_payload(result) = DataFetcher.new.coerce_payload(result)
|
|
36
|
+
def self.deep_dig_indifferent(obj, keys) = RefParser.new.dig(obj, keys)
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def has_api?
|
|
41
|
+
api_name = component["api"]
|
|
42
|
+
return true if inline_source
|
|
43
|
+
!api_name.nil? && !api_name.to_s.strip.empty?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Resolution order per component: explicitly injected data_sources win,
|
|
47
|
+
# then inline data (when given, it answers any api name and components
|
|
48
|
+
# without one), then the global registry. Injected and inline sources
|
|
49
|
+
# never touch shared state (thread-safe by construction).
|
|
50
|
+
def data_source
|
|
51
|
+
@data_source ||= data_sources[normalize_source_name(component["api"])] ||
|
|
52
|
+
inline_source ||
|
|
53
|
+
DataSource.for(component["api"])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def normalize_data_sources(sources)
|
|
57
|
+
(sources || {}).each_with_object({}) do |(name, source), normalized|
|
|
58
|
+
normalized[normalize_source_name(name)] = source.is_a?(Class) ? source.new : source
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def normalize_source_name(name)
|
|
63
|
+
name.to_s.strip.downcase
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def request_params
|
|
67
|
+
@request_params ||= @fetcher.merge_api_params(component, query_params)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# --- Data pipeline ---
|
|
71
|
+
|
|
72
|
+
def fetch_and_prepare(references_source)
|
|
73
|
+
references = @refs.extract_references(references_source)
|
|
74
|
+
parsed_refs = references.map { |ref| @refs.parse(ref) }
|
|
75
|
+
ref_keys = parsed_refs.map { |p| @refs.ref_key(p) }.uniq
|
|
76
|
+
|
|
77
|
+
payload_by_ref_key = {}
|
|
78
|
+
ref_keys.each do |key|
|
|
79
|
+
parsed = parsed_refs.find { |p| @refs.ref_key(p) == key }
|
|
80
|
+
payload_by_ref_key[key] = @fetcher.coerce_payload(
|
|
81
|
+
data_source.fetch(parsed[:endpoint], request_params, resource: parsed[:resource])
|
|
82
|
+
)
|
|
83
|
+
warn_no_data(key) if payload_by_ref_key[key]["data"].empty?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@raw_data = payload_by_ref_key.transform_values { |p| CoreExt::Hash.deep_dup(p) }
|
|
87
|
+
payload_by_ref_key
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def transform_rows(payload_by_ref_key)
|
|
91
|
+
rows_by_ref_key = payload_by_ref_key.transform_values { |p| p["data"] }
|
|
92
|
+
counts_before = rows_by_ref_key.transform_values(&:length)
|
|
93
|
+
|
|
94
|
+
rows_by_ref_key = @transform.apply_computed_fields(rows_by_ref_key, payload_by_ref_key, component)
|
|
95
|
+
rows_by_ref_key = @transform.apply_postprocess(rows_by_ref_key, component, @warnings)
|
|
96
|
+
|
|
97
|
+
rows_by_ref_key.each do |key, rows|
|
|
98
|
+
warn_postprocess_emptied(key, counts_before[key]) if rows.empty? && counts_before[key].to_i > 0
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
rows_by_ref_key
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- Component renderers ---
|
|
105
|
+
|
|
106
|
+
def render_chart
|
|
107
|
+
payload_by_ref_key = fetch_and_prepare(component["highchartsData"])
|
|
108
|
+
rows_by_ref_key = transform_rows(payload_by_ref_key)
|
|
109
|
+
highcharts = build_highcharts(rows_by_ref_key, payload_by_ref_key)
|
|
110
|
+
|
|
111
|
+
build_result("chart", "highchartsData" => highcharts, "display" => component["display"])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def render_score
|
|
115
|
+
display = component["display"] || {}
|
|
116
|
+
payload_by_ref_key = fetch_and_prepare(display)
|
|
117
|
+
rows_by_ref_key = payload_by_ref_key.transform_values { |p| p["data"] }
|
|
118
|
+
rows_by_ref_key = @transform.apply_computed_fields(rows_by_ref_key, payload_by_ref_key, component)
|
|
119
|
+
resolved_display = resolve_refs(display, payload_by_ref_key, rows_by_ref_key)
|
|
120
|
+
|
|
121
|
+
build_result("score", "display" => resolved_display)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def render_table
|
|
125
|
+
return nil unless component["tableData"]
|
|
126
|
+
|
|
127
|
+
table_def = component["tableData"]
|
|
128
|
+
column_defs = Array(table_def["columns"])
|
|
129
|
+
|
|
130
|
+
payload_by_ref_key = fetch_and_prepare(column_defs)
|
|
131
|
+
rows_by_ref_key = transform_rows(payload_by_ref_key)
|
|
132
|
+
|
|
133
|
+
primary_rk = payload_by_ref_key.keys.first
|
|
134
|
+
rows = primary_rk ? (rows_by_ref_key[primary_rk] || []) : []
|
|
135
|
+
|
|
136
|
+
output_rows = rows.map do |row|
|
|
137
|
+
column_defs.each_with_object({}) do |col_def, output_row|
|
|
138
|
+
identifier = col_def["identifier"]
|
|
139
|
+
next if identifier.nil? || identifier.empty?
|
|
140
|
+
output_row[identifier] = resolve_cell_value(col_def["value"], row, payload_by_ref_key)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
{"id" => component["id"], "type" => "table",
|
|
145
|
+
"tableData" => {"headers" => table_def["headers"], "columns" => output_rows}.compact}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def render_filter
|
|
149
|
+
filter_defs = Array(component["filters"])
|
|
150
|
+
return nil if filter_defs.empty?
|
|
151
|
+
|
|
152
|
+
rows_cache = {}
|
|
153
|
+
|
|
154
|
+
resolved_filters = filter_defs.map do |filter_def|
|
|
155
|
+
options_template = filter_def["options"] || {}
|
|
156
|
+
references = @refs.extract_references(options_template)
|
|
157
|
+
parsed = references.map { |ref| @refs.parse(ref) }.first
|
|
158
|
+
rk = parsed ? @refs.ref_key(parsed) : nil
|
|
159
|
+
|
|
160
|
+
payload = if parsed
|
|
161
|
+
rows_cache[rk] ||= @fetcher.coerce_payload(
|
|
162
|
+
data_source.fetch(parsed[:endpoint], request_params, resource: parsed[:resource])
|
|
163
|
+
)
|
|
164
|
+
else
|
|
165
|
+
{"data" => [], "meta" => {}}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
options = payload["data"].map do |row|
|
|
169
|
+
options_template.transform_values { |tmpl| resolve_filter_value(tmpl, row) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
filter_def.merge("options" => options).except("queries")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
{"id" => component["id"], "type" => "filter", "filters" => resolved_filters}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def render_text
|
|
179
|
+
{"id" => component["id"], "type" => "text", "text" => component["text"],
|
|
180
|
+
"subheader" => component["subheader"], "body" => component["body"]}.compact
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# --- Highcharts builder ---
|
|
184
|
+
|
|
185
|
+
def build_highcharts(rows_by_ref_key, payload_by_ref_key)
|
|
186
|
+
template = CoreExt::Hash.deep_dup(component["highchartsData"])
|
|
187
|
+
series_templates = template.delete("series") || []
|
|
188
|
+
all_categories = nil
|
|
189
|
+
|
|
190
|
+
built_series = series_templates.map do |s_tmpl|
|
|
191
|
+
data_template = s_tmpl.delete("data") || {}
|
|
192
|
+
rk = detect_ref_key(data_template, rows_by_ref_key)
|
|
193
|
+
rows = rk ? (rows_by_ref_key[rk] || []) : []
|
|
194
|
+
meta = rk ? ((payload_by_ref_key[rk] || {})["meta"] || {}) : {}
|
|
195
|
+
|
|
196
|
+
x_field_keys = extract_category_keys(data_template["x"])
|
|
197
|
+
data_points = build_data_points(data_template, rows, meta, rk)
|
|
198
|
+
all_categories = rows.map { |row| @refs.dig(row, x_field_keys) } if x_field_keys
|
|
199
|
+
|
|
200
|
+
resolve_series_fields(s_tmpl, rows, meta: meta).merge("data" => data_points)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
template["xAxis"] = inject_categories(template["xAxis"], all_categories) if all_categories
|
|
204
|
+
resolve_refs(template, payload_by_ref_key).merge("series" => built_series)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def build_data_points(data_template, rows, meta, current_ref_key)
|
|
208
|
+
rows.each_with_index.map do |row, idx|
|
|
209
|
+
data_template.each_with_object({}) do |(key, val), point|
|
|
210
|
+
point[key] = resolve_data_value(key, val, row, idx, meta: meta, current_ref_key: current_ref_key)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# --- Reference resolution ---
|
|
216
|
+
|
|
217
|
+
def resolve_refs(obj, payload_by_ref_key, rows_override = nil)
|
|
218
|
+
case obj
|
|
219
|
+
when Hash then obj.transform_values { |v| resolve_refs(v, payload_by_ref_key, rows_override) }
|
|
220
|
+
when Array then obj.map { |v| resolve_refs(v, payload_by_ref_key, rows_override) }
|
|
221
|
+
when String
|
|
222
|
+
return obj unless obj.start_with?("$")
|
|
223
|
+
parsed = @refs.parse(obj.delete_prefix("$"))
|
|
224
|
+
rk = @refs.ref_key(parsed)
|
|
225
|
+
payload = payload_by_ref_key[rk] || {}
|
|
226
|
+
if parsed[:scope] == :meta
|
|
227
|
+
@refs.dig(payload["meta"] || {}, parsed[:keys]) || obj
|
|
228
|
+
else
|
|
229
|
+
rows = rows_override&.dig(rk) || payload["data"] || []
|
|
230
|
+
@refs.dig(rows.first, parsed[:keys]) || obj
|
|
231
|
+
end
|
|
232
|
+
else obj
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def resolve_data_value(key, val, row, index, meta:, current_ref_key:)
|
|
237
|
+
if val.is_a?(Hash)
|
|
238
|
+
return val.transform_values { |v| resolve_data_value(nil, v, row, index, meta: meta, current_ref_key: current_ref_key) }
|
|
239
|
+
end
|
|
240
|
+
return val unless val.is_a?(String) && val.start_with?("$")
|
|
241
|
+
|
|
242
|
+
parsed = @refs.parse(val.delete_prefix("$"))
|
|
243
|
+
rk = @refs.ref_key(parsed)
|
|
244
|
+
|
|
245
|
+
if rk != current_ref_key
|
|
246
|
+
return index if key == "x"
|
|
247
|
+
return row[parsed[:endpoint]] || row[parsed[:endpoint].to_sym] if parsed[:legacy] && parsed[:keys].empty?
|
|
248
|
+
return nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
return index if key == "x"
|
|
252
|
+
return @refs.dig(meta, parsed[:keys]) if parsed[:scope] == :meta
|
|
253
|
+
@refs.dig(row, parsed[:keys])
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def resolve_series_fields(s_tmpl, rows, meta:)
|
|
257
|
+
s_tmpl.each_with_object({}) do |(key, val), result|
|
|
258
|
+
if val.is_a?(String) && val.start_with?("$")
|
|
259
|
+
parsed = @refs.parse(val.delete_prefix("$"))
|
|
260
|
+
row_keys = (parsed[:legacy] && parsed[:keys].empty?) ? [parsed[:endpoint]] : parsed[:keys]
|
|
261
|
+
resolved = if parsed[:scope] == :meta
|
|
262
|
+
@refs.dig(meta, parsed[:keys])
|
|
263
|
+
else
|
|
264
|
+
@refs.dig(rows.first, row_keys)
|
|
265
|
+
end
|
|
266
|
+
result[key] = resolved.nil? ? val : resolved
|
|
267
|
+
else
|
|
268
|
+
result[key] = val
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def resolve_cell_value(value_ref, row, payload_by_ref_key)
|
|
274
|
+
if value_ref.is_a?(String) && value_ref.start_with?("$")
|
|
275
|
+
parsed = @refs.parse(value_ref.delete_prefix("$"))
|
|
276
|
+
rk = @refs.ref_key(parsed)
|
|
277
|
+
ep_meta = (payload_by_ref_key[rk] || {})["meta"] || {}
|
|
278
|
+
row_keys = (parsed[:legacy] && parsed[:keys].empty?) ? [parsed[:endpoint]] : parsed[:keys]
|
|
279
|
+
(parsed[:scope] == :meta) ? @refs.dig(ep_meta, parsed[:keys]) : @refs.dig(row, row_keys)
|
|
280
|
+
else
|
|
281
|
+
value_ref
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def resolve_filter_value(tmpl, row)
|
|
286
|
+
return tmpl unless tmpl.is_a?(String)
|
|
287
|
+
|
|
288
|
+
tmpl.gsub(/\$([a-zA-Z0-9_-]+)\.data\.([a-zA-Z0-9_.]+)/) do
|
|
289
|
+
path = Regexp.last_match(2).split(".")
|
|
290
|
+
@refs.dig(row, path) || Regexp.last_match(0)
|
|
291
|
+
end.gsub(/\$([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_]+)/) do
|
|
292
|
+
field = Regexp.last_match(2)
|
|
293
|
+
next Regexp.last_match(0) if %w[data meta].include?(field)
|
|
294
|
+
(row[field] || row[field.to_sym]) || Regexp.last_match(0)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# --- Helpers ---
|
|
299
|
+
|
|
300
|
+
def detect_ref_key(data_template, rows_by_ref_key = {})
|
|
301
|
+
first_candidate = nil
|
|
302
|
+
data_template.each_value do |val|
|
|
303
|
+
next unless val.is_a?(String) && val.start_with?("$")
|
|
304
|
+
rk = @refs.ref_key_for(val.delete_prefix("$"))
|
|
305
|
+
return rk if rows_by_ref_key.key?(rk)
|
|
306
|
+
first_candidate ||= rk
|
|
307
|
+
end
|
|
308
|
+
first_candidate
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def extract_category_keys(x_ref)
|
|
312
|
+
return nil unless x_ref.is_a?(String) && x_ref.start_with?("$")
|
|
313
|
+
p = @refs.parse(x_ref.delete_prefix("$"))
|
|
314
|
+
return nil if p[:scope] == :meta
|
|
315
|
+
return [p[:endpoint]] if p[:legacy] && p[:keys].empty?
|
|
316
|
+
p[:keys].empty? ? nil : p[:keys]
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def inject_categories(x_axis, categories)
|
|
320
|
+
case x_axis
|
|
321
|
+
when Array then x_axis.map { |axis| axis.merge("categories" => categories) }
|
|
322
|
+
when Hash then [x_axis.merge("categories" => categories)]
|
|
323
|
+
else [{"categories" => categories}]
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def build_result(type, fields = {})
|
|
328
|
+
result = {"id" => component["id"], "type" => type}.merge(fields).compact
|
|
329
|
+
result["warnings"] = @warnings if @warnings.any?
|
|
330
|
+
result
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def warn_no_data(rk)
|
|
334
|
+
msg = "No data returned for '#{rk}'"
|
|
335
|
+
@warnings << msg
|
|
336
|
+
Trevl.logger.warn("[Trevl::Renderer] #{msg} (component: #{component["id"]})")
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def warn_postprocess_emptied(rk, count_before)
|
|
340
|
+
msg = "Postprocess removed all #{count_before} rows for '#{rk}'"
|
|
341
|
+
@warnings << msg
|
|
342
|
+
Trevl.logger.warn("[Trevl::Renderer] #{msg} (component: #{component["id"]})")
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|