lcp 0.1.0 → 0.2.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 +4 -4
- data/.claude/skills/lcp-custom-field/SKILL.md +10 -1
- data/.claude/skills/lcp-custom-field/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-getting-started/SKILL.md +1 -1
- data/.claude/skills/lcp-getting-started/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-host-binding/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-model/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-permissions/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-presenter/agents/openai.yaml +4 -0
- data/.claude/skills/lcp-workflow/SKILL.md +10 -2
- data/.claude/skills/lcp-workflow/agents/openai.yaml +4 -0
- data/CHANGELOG.md +59 -1
- data/app/assets/javascripts/lcp_ruby/dev_toolbar.js +5 -6
- data/app/controllers/concerns/lcp_ruby/zone_resolution.rb +14 -1
- data/app/helpers/lcp_ruby/display/card_helper.rb +71 -16
- data/app/helpers/lcp_ruby/display_helper.rb +30 -3
- data/app/helpers/lcp_ruby/display_template_helper.rb +3 -3
- data/app/helpers/lcp_ruby/layout_helper.rb +49 -8
- data/app/helpers/lcp_ruby/tree_helper.rb +24 -3
- data/app/models/lcp_ruby/user.rb +10 -0
- data/app/views/layouts/lcp_ruby/application.html.erb +42 -20
- data/app/views/layouts/lcp_ruby/auth.html.erb +6 -1
- data/app/views/lcp_ruby/resources/_show_sections.html.erb +24 -17
- data/app/views/lcp_ruby/resources/_table_index.html.erb +12 -17
- data/app/views/lcp_ruby/widgets/_markdown.html.erb +13 -0
- data/app/views/lcp_ruby/widgets/_presenter_zone.html.erb +23 -7
- data/app/views/lcp_ruby/widgets/_rich_text.html.erb +25 -0
- data/docs/README.md +3 -0
- data/docs/feature-catalog.md +5 -2
- data/docs/feature-catalog.yml +89 -9
- data/docs/getting-started.md +38 -22
- data/docs/guides/dashboards.md +44 -1
- data/docs/guides/display-types.md +6 -0
- data/docs/guides/host-application.md +1 -1
- data/docs/guides/windows-setup.md +211 -0
- data/docs/guides/workflow.md +1 -1
- data/docs/reference/asset-pipeline.md +79 -0
- data/docs/reference/pages.md +50 -1
- data/docs/reference/presenters.md +6 -0
- data/docs/reference/workflow.md +2 -2
- data/examples/crm/Gemfile +3 -0
- data/examples/crm/Gemfile.lock +14 -9
- data/examples/crm/app/assets/config/manifest.js +4 -0
- data/examples/hr/Gemfile +3 -0
- data/examples/hr/Gemfile.lock +14 -9
- data/examples/hr/app/assets/config/manifest.js +4 -0
- data/examples/showcase/Gemfile +3 -0
- data/examples/showcase/Gemfile.lock +12 -9
- data/examples/showcase/app/assets/config/manifest.js +4 -0
- data/examples/showcase/config/lcp_ruby/pages/main_dashboard.yml +17 -2
- data/examples/showcase/config/locales/cs.yml +8 -0
- data/examples/showcase/config/locales/en.yml +8 -0
- data/examples/todo/Gemfile +3 -0
- data/examples/todo/Gemfile.lock +14 -9
- data/examples/todo/app/assets/config/manifest.js +4 -0
- data/exe/lcp +1 -1
- data/lib/generators/lcp_ruby/agent_setup_generator.rb +12 -9
- data/lib/generators/lcp_ruby/claude_skills_generator.rb +23 -19
- data/lib/generators/lcp_ruby/install_generator.rb +171 -3
- data/lib/generators/lcp_ruby/templates/agent_setup/agents_md.md +2 -1
- data/lib/generators/lcp_ruby/templates/agent_setup/claude_md.md +3 -2
- data/lib/generators/lcp_ruby/templates/install/menu.yml.tt +4 -0
- data/lib/generators/lcp_ruby/templates/monitoring/page.yml +5 -0
- data/lib/lcp_ruby/app_template.rb +37 -1
- data/lib/lcp_ruby/array_query.rb +33 -10
- data/lib/lcp_ruby/cli/new_command.rb +9 -4
- data/lib/lcp_ruby/cli/run_command.rb +98 -11
- data/lib/lcp_ruby/cli/skills_command.rb +27 -15
- data/lib/lcp_ruby/cli.rb +5 -1
- data/lib/lcp_ruby/configuration.rb +13 -2
- data/lib/lcp_ruby/custom_fields/applicator.rb +8 -0
- data/lib/lcp_ruby/custom_fields/query.rb +28 -20
- data/lib/lcp_ruby/display/markdown_sanitize.rb +36 -0
- data/lib/lcp_ruby/display/renderers/markdown.rb +6 -13
- data/lib/lcp_ruby/engine.rb +185 -1
- data/lib/lcp_ruby/export/data_generator.rb +5 -1
- data/lib/lcp_ruby/export/value_formatter.rb +34 -0
- data/lib/lcp_ruby/grouped_query/builder.rb +21 -0
- data/lib/lcp_ruby/metadata/configuration_validator.rb +5 -5
- data/lib/lcp_ruby/metadata/field_path.rb +57 -0
- data/lib/lcp_ruby/metadata/loader.rb +33 -1
- data/lib/lcp_ruby/metadata/menu_definition.rb +32 -2
- data/lib/lcp_ruby/metadata/menu_item.rb +53 -4
- data/lib/lcp_ruby/metadata/model_definition.rb +9 -0
- data/lib/lcp_ruby/metadata/zone_definition.rb +12 -5
- data/lib/lcp_ruby/metrics/json_query.rb +5 -0
- data/lib/lcp_ruby/model_factory/builder.rb +5 -0
- data/lib/lcp_ruby/model_factory/json_type_applicator.rb +35 -0
- data/lib/lcp_ruby/model_factory/label_method_builder.rb +3 -19
- data/lib/lcp_ruby/model_factory/schema_manager.rb +19 -0
- data/lib/lcp_ruby/pages/definition_validator.rb +13 -0
- data/lib/lcp_ruby/presenter/field_value_resolver.rb +36 -19
- data/lib/lcp_ruby/schemas/page.json +33 -1
- data/lib/lcp_ruby/search/custom_field_filter.rb +5 -0
- data/lib/lcp_ruby/skills_installer.rb +15 -2
- data/lib/lcp_ruby/tasks/doctor.rb +13 -8
- data/lib/lcp_ruby/version.rb +1 -1
- data/lib/lcp_ruby/widgets/data_resolver.rb +42 -1
- data/lib/lcp_ruby/widgets/date_grouper.rb +19 -0
- data/lib/lcp_ruby/workflow/contract_validator.rb +1 -1
- data/lib/lcp_ruby/workflow/model_source.rb +2 -2
- data/lib/lcp_ruby.rb +18 -0
- data/lib/tasks/lcp_ruby_db.rake +8 -1
- data/lib/tasks/lcp_ruby_i18n_check.rake +8 -0
- metadata +34 -24
- data/examples/crm/erd.md +0 -163
- data/examples/hr/erd.md +0 -396
- data/examples/showcase/erd.md +0 -567
- data/examples/todo/erd.md +0 -21
|
@@ -78,7 +78,11 @@ module LcpRuby
|
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
def resolve_and_format(record, field_path, output: :csv)
|
|
81
|
-
|
|
81
|
+
# Resolver now always returns raw enum keys; the formatter is the
|
|
82
|
+
# single source of enum-mode rendering (`label` / `key` / `both`).
|
|
83
|
+
# The `@raw` flag controls the formatter's native-type output for
|
|
84
|
+
# raw `.json` / `.csv` endpoints.
|
|
85
|
+
value = @field_value_resolver.resolve(record, field_path)
|
|
82
86
|
field_def, model_name = field_meta[field_path]
|
|
83
87
|
|
|
84
88
|
@formatter.format(value, field_definition: field_def, model_name: model_name, raw: @raw, output: output)
|
|
@@ -30,6 +30,32 @@ module LcpRuby
|
|
|
30
30
|
|
|
31
31
|
type = field_definition&.type || infer_type(value)
|
|
32
32
|
|
|
33
|
+
# has_many → terminal dot-paths (e.g. `tasks.status`, `tasks.due_date`,
|
|
34
|
+
# `tasks.title`) deliver an Array of terminal values. Format each element
|
|
35
|
+
# through the SAME type logic and recombine — a single chokepoint so
|
|
36
|
+
# every type is handled, not just enum. Without it, non-enum Arrays fell
|
|
37
|
+
# through to scalar parsers: `format_date([d1, d2])` parsed only the
|
|
38
|
+
# first date (silent data loss) and string terminals emitted the Ruby
|
|
39
|
+
# array literal `["a", "b"]`.
|
|
40
|
+
#
|
|
41
|
+
# Gate on the EXPLICIT field type, not `type`: only a declared `json`
|
|
42
|
+
# field holds an Array that must serialize as JSON. When the field def
|
|
43
|
+
# can't be resolved (field_definition nil), `type` would be
|
|
44
|
+
# `infer_type(Array) == "json"` and wrongly skip the join — so a
|
|
45
|
+
# has_many dot-path with no resolvable terminal would still emit the
|
|
46
|
+
# Ruby array literal. Checking field_definition&.type keeps that case
|
|
47
|
+
# joined.
|
|
48
|
+
if value.is_a?(Array) && field_definition&.type != "json"
|
|
49
|
+
formatted = value.map do |v|
|
|
50
|
+
format(v, field_definition: field_definition, model_name: model_name, raw: raw, output: output)
|
|
51
|
+
end
|
|
52
|
+
# Raw JSON wants a native array of typed values; CSV and the
|
|
53
|
+
# locale-formatted label modes want a single joined cell.
|
|
54
|
+
return formatted if raw && output != :csv
|
|
55
|
+
|
|
56
|
+
return formatted.compact.join(array_separator)
|
|
57
|
+
end
|
|
58
|
+
|
|
33
59
|
return format_raw(value, type, output) if raw
|
|
34
60
|
|
|
35
61
|
case type
|
|
@@ -57,6 +83,8 @@ module LcpRuby
|
|
|
57
83
|
def format_raw(value, type, output)
|
|
58
84
|
case type
|
|
59
85
|
when "enum"
|
|
86
|
+
# Array values are joined/native-arrayed by the #format chokepoint
|
|
87
|
+
# before dispatch, so this only sees scalar raw keys.
|
|
60
88
|
value.to_s
|
|
61
89
|
when "date"
|
|
62
90
|
parse_date(value)&.iso8601 || value.to_s
|
|
@@ -124,6 +152,8 @@ module LcpRuby
|
|
|
124
152
|
end
|
|
125
153
|
|
|
126
154
|
def format_enum(value, field_def, model_name)
|
|
155
|
+
# Array values (has_many enum dot-paths) are handled by the Array
|
|
156
|
+
# chokepoint in #format before dispatch, so this only sees scalars.
|
|
127
157
|
mode = @options["enum_mode"] || "label"
|
|
128
158
|
key = value.to_s
|
|
129
159
|
|
|
@@ -138,6 +168,10 @@ module LcpRuby
|
|
|
138
168
|
end
|
|
139
169
|
end
|
|
140
170
|
|
|
171
|
+
def array_separator
|
|
172
|
+
@options["array_separator"] || ", "
|
|
173
|
+
end
|
|
174
|
+
|
|
141
175
|
def enum_label(key, field_def, model_name)
|
|
142
176
|
return key.humanize unless field_def
|
|
143
177
|
field_def.enum_label_for(key, model_name: model_name)
|
|
@@ -181,6 +181,8 @@ module LcpRuby
|
|
|
181
181
|
def date_trunc_sql(period, col_ref, conn)
|
|
182
182
|
if LcpRuby.postgresql?
|
|
183
183
|
"DATE_TRUNC('#{period}', #{col_ref})"
|
|
184
|
+
elsif LcpRuby.mysql?
|
|
185
|
+
mysql_trunc(period, col_ref)
|
|
184
186
|
else
|
|
185
187
|
sqlite_trunc(period, col_ref)
|
|
186
188
|
end
|
|
@@ -200,6 +202,25 @@ module LcpRuby
|
|
|
200
202
|
"strftime('#{format}', #{col_ref})"
|
|
201
203
|
end
|
|
202
204
|
end
|
|
205
|
+
|
|
206
|
+
# MySQL/MariaDB has no strftime; DATE_FORMAT produces the same period-key
|
|
207
|
+
# strings ("2026-01", "2026") that GroupedQueryHelper#parse_period_value
|
|
208
|
+
# consumes, so the helper stays adapter-agnostic. Quarter is built with
|
|
209
|
+
# QUARTER() to match SQLite's "2026-Q1" shape.
|
|
210
|
+
def mysql_trunc(period, col_ref)
|
|
211
|
+
case period
|
|
212
|
+
when "quarter"
|
|
213
|
+
"CONCAT(YEAR(#{col_ref}), '-Q', QUARTER(#{col_ref}))"
|
|
214
|
+
else
|
|
215
|
+
format = case period
|
|
216
|
+
when "day" then "%Y-%m-%d"
|
|
217
|
+
when "week" then "%Y-%u"
|
|
218
|
+
when "month" then "%Y-%m"
|
|
219
|
+
when "year" then "%Y"
|
|
220
|
+
end
|
|
221
|
+
"DATE_FORMAT(#{col_ref}, '#{format}')"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
203
224
|
end
|
|
204
225
|
end
|
|
205
226
|
end
|
|
@@ -5617,7 +5617,7 @@ module LcpRuby
|
|
|
5617
5617
|
validate_workflow_graph_widget(page, zone, widget)
|
|
5618
5618
|
end
|
|
5619
5619
|
|
|
5620
|
-
if widget["link_to"] && widget_type
|
|
5620
|
+
if widget["link_to"] && !LcpRuby::Metadata::ZoneDefinition::I18N_TEXT_WIDGET_TYPES.include?(widget_type)
|
|
5621
5621
|
unless all_slugs.include?(widget["link_to"])
|
|
5622
5622
|
@warnings << "Page '#{page.name}', zone '#{zone.name}': widget link_to '#{widget["link_to"]}' " \
|
|
5623
5623
|
"does not match any known page slug"
|
|
@@ -5858,8 +5858,8 @@ module LcpRuby
|
|
|
5858
5858
|
|
|
5859
5859
|
model_name = resolve_zone_model(page, zone)
|
|
5860
5860
|
unless model_name
|
|
5861
|
-
if zone.widget? && zone.widget["type"]
|
|
5862
|
-
@warnings << "Page '#{page.name}', zone '#{zone.name}':
|
|
5861
|
+
if zone.widget? && LcpRuby::Metadata::ZoneDefinition::I18N_TEXT_WIDGET_TYPES.include?(zone.widget["type"])
|
|
5862
|
+
@warnings << "Page '#{page.name}', zone '#{zone.name}': #{zone.widget["type"]} widget has scope but no model context"
|
|
5863
5863
|
end
|
|
5864
5864
|
return
|
|
5865
5865
|
end
|
|
@@ -5876,8 +5876,8 @@ module LcpRuby
|
|
|
5876
5876
|
|
|
5877
5877
|
model_name = resolve_zone_model(page, zone)
|
|
5878
5878
|
unless model_name
|
|
5879
|
-
if zone.widget? && zone.widget["type"]
|
|
5880
|
-
@warnings << "Page '#{page.name}', zone '#{zone.name}':
|
|
5879
|
+
if zone.widget? && LcpRuby::Metadata::ZoneDefinition::I18N_TEXT_WIDGET_TYPES.include?(zone.widget["type"])
|
|
5880
|
+
@warnings << "Page '#{page.name}', zone '#{zone.name}': #{zone.widget["type"]} widget has default_scope but no model context"
|
|
5881
5881
|
end
|
|
5882
5882
|
return
|
|
5883
5883
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module LcpRuby
|
|
2
|
+
module Metadata
|
|
3
|
+
# Walks a dot-path field reference through metadata to find the terminal
|
|
4
|
+
# field definition and its owning model name.
|
|
5
|
+
#
|
|
6
|
+
# Used by view templates and helpers that need to humanize/route an enum
|
|
7
|
+
# value coming from a cross-model field path like `"contact.tier"` or
|
|
8
|
+
# `"order.customer.status"`. The terminal model_name is the i18n
|
|
9
|
+
# namespace under which `enum_label_for` should look up labels
|
|
10
|
+
# (`lcp_ruby.enums.<model>.<field>.*`).
|
|
11
|
+
#
|
|
12
|
+
# Returns `[FieldDefinition, model_name]` or `[nil, nil]` when:
|
|
13
|
+
# - model_def is nil or path is blank
|
|
14
|
+
# - any intermediate segment is missing / non-LCP
|
|
15
|
+
# - an intermediate segment is a collection (`has_many`) and
|
|
16
|
+
# `through_collections:` is false (the default)
|
|
17
|
+
# - any segment cannot be loaded from the metadata loader
|
|
18
|
+
#
|
|
19
|
+
# `through_collections:` — when false (default), an intermediate
|
|
20
|
+
# `has_many` aborts the walk (`[nil, nil]`). This is what
|
|
21
|
+
# `LabelMethodBuilder` wants: a `to_label` walks a singular chain with
|
|
22
|
+
# `public_send`, so a collection mid-path can't yield a scalar value.
|
|
23
|
+
# Display surfaces (index tables, cards, tree) pass `true`: a has_many
|
|
24
|
+
# terminal enum (e.g. `contacts.tier`) resolves to an Array of values
|
|
25
|
+
# that the field def still humanizes element-wise (issue #18). The
|
|
26
|
+
# terminal field def is identical either way — the flag only governs
|
|
27
|
+
# whether a collection mid-path is allowed.
|
|
28
|
+
#
|
|
29
|
+
# Callers treat `[nil, nil]` as "no metadata available — pass through".
|
|
30
|
+
module FieldPath
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def terminal(model_def, path, through_collections: false)
|
|
34
|
+
return [ nil, nil ] if model_def.nil?
|
|
35
|
+
|
|
36
|
+
path_str = path.to_s
|
|
37
|
+
return [ nil, nil ] if path_str.empty?
|
|
38
|
+
|
|
39
|
+
parts = path_str.split(".")
|
|
40
|
+
current_def = model_def
|
|
41
|
+
|
|
42
|
+
parts[0..-2].each do |segment|
|
|
43
|
+
assoc = current_def.associations.find { |a| a.name == segment }
|
|
44
|
+
return [ nil, nil ] unless assoc&.lcp_model?
|
|
45
|
+
return [ nil, nil ] unless through_collections || assoc.singular?
|
|
46
|
+
|
|
47
|
+
current_def = LcpRuby.loader.model_definition(assoc.target_model)
|
|
48
|
+
return [ nil, nil ] if current_def.nil?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
[ current_def.field(parts.last), current_def.name ]
|
|
52
|
+
rescue MetadataError
|
|
53
|
+
[ nil, nil ]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -701,16 +701,42 @@ module LcpRuby
|
|
|
701
701
|
end
|
|
702
702
|
|
|
703
703
|
data = YAML.safe_load_file(menu_file, permitted_classes: [ Symbol, Regexp ])
|
|
704
|
-
|
|
704
|
+
|
|
705
|
+
# An empty / comment-only menu.yml parses to nil. In :strict mode (no
|
|
706
|
+
# auto-append) that is an empty nav bar with no signal — fail loud,
|
|
707
|
+
# same as a present-but-content-less menu below.
|
|
708
|
+
if data.nil?
|
|
709
|
+
raise_strict_empty_menu! if menu_mode == :strict
|
|
710
|
+
return
|
|
711
|
+
end
|
|
705
712
|
|
|
706
713
|
@menu_definition = MenuDefinition.from_hash(data)
|
|
707
714
|
validate_menu_references!
|
|
708
715
|
|
|
716
|
+
# :strict mode is authoritative and does NOT auto-populate from view
|
|
717
|
+
# groups, so a menu.yml that declares no navigation — neither top_menu
|
|
718
|
+
# nor sidebar_menu, an empty list (`top_menu: []`), or a `responsive:`-
|
|
719
|
+
# only block — would boot with an empty nav bar and no signal. `blank?`
|
|
720
|
+
# catches nil AND []. In :auto mode this is fine: auto-append fills
|
|
721
|
+
# top_menu below.
|
|
722
|
+
if menu_mode == :strict &&
|
|
723
|
+
@menu_definition.top_menu.blank? && @menu_definition.sidebar_menu.blank?
|
|
724
|
+
raise_strict_empty_menu!
|
|
725
|
+
end
|
|
726
|
+
|
|
709
727
|
auto_append_unreferenced_view_groups! if menu_mode == :auto
|
|
710
728
|
rescue Psych::SyntaxError => e
|
|
711
729
|
raise MetadataError, "YAML syntax error in #{menu_file}: #{e.message}"
|
|
712
730
|
end
|
|
713
731
|
|
|
732
|
+
def raise_strict_empty_menu!
|
|
733
|
+
raise MetadataError,
|
|
734
|
+
"menu.yml declares no navigation (neither top_menu nor sidebar_menu) but " \
|
|
735
|
+
"menu_mode is :strict, which does not auto-populate the menu from view groups — " \
|
|
736
|
+
"the app would render with an empty navigation bar. Add a top_menu or sidebar_menu, " \
|
|
737
|
+
"or switch to menu_mode :auto."
|
|
738
|
+
end
|
|
739
|
+
|
|
714
740
|
def load_theme
|
|
715
741
|
theme_file = %w[theme.yml theme.yaml].map { |f| base_path.join(f) }.find(&:exist?)
|
|
716
742
|
return unless theme_file
|
|
@@ -774,6 +800,12 @@ module LcpRuby
|
|
|
774
800
|
|
|
775
801
|
def validate_strict_mode!
|
|
776
802
|
@view_group_definitions.each_value do |vg|
|
|
803
|
+
# Auto-created VGs (no author-written file) inherit a default
|
|
804
|
+
# `{ menu: "main", position: X }` for `:auto` mode placement.
|
|
805
|
+
# In `:strict` mode that placement is meaningless (menu.yml is
|
|
806
|
+
# authoritative) and there's no file for the configurator to
|
|
807
|
+
# edit — skip them rather than forcing per-VG workaround files.
|
|
808
|
+
next if vg.source_type == "auto"
|
|
777
809
|
next unless vg.navigable?
|
|
778
810
|
|
|
779
811
|
nav = vg.navigation_config
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
module LcpRuby
|
|
2
2
|
module Metadata
|
|
3
3
|
class MenuDefinition
|
|
4
|
+
# Every recognized top-level key in menu.yml. Mirrors
|
|
5
|
+
# `lib/lcp_ruby/schemas/menu.json` (`additionalProperties: false`).
|
|
6
|
+
# `validate!` rejects anything else so a typo (`top_men:`,
|
|
7
|
+
# `side_menu:`) fails fast at boot instead of silently dropping the
|
|
8
|
+
# whole menu — the same footgun MenuItem.reject_unknown_keys! guards
|
|
9
|
+
# against at the item level.
|
|
10
|
+
KNOWN_TOP_LEVEL_KEYS = %w[top_menu sidebar_menu responsive].freeze
|
|
11
|
+
|
|
4
12
|
attr_reader :top_menu, :sidebar_menu, :raw_hash, :responsive
|
|
5
13
|
|
|
6
14
|
# Class-level flag so the auto-inject info log fires once per Ruby
|
|
@@ -90,9 +98,31 @@ module LcpRuby
|
|
|
90
98
|
end
|
|
91
99
|
|
|
92
100
|
def validate!
|
|
93
|
-
|
|
94
|
-
|
|
101
|
+
# Both menus nil is a valid state: a fresh-install menu.yml carries
|
|
102
|
+
# only `# lcp:menu` insertion markers (or a `responsive:`-only block),
|
|
103
|
+
# and in :auto mode auto-append populates top_menu from view groups.
|
|
104
|
+
# A truly empty/comment-only file never reaches here (the loader
|
|
105
|
+
# returns before `from_hash` when YAML parses to nil).
|
|
106
|
+
#
|
|
107
|
+
# But an empty file and a *typo'd* one (`top_men:`/`side_menu:`) used
|
|
108
|
+
# to be indistinguishable — both parse to both-nil and would silently
|
|
109
|
+
# drop a fully-configured menu. Reject unknown top-level keys (with a
|
|
110
|
+
# DidYouMean hint) so the typo fails fast; known-key-only files
|
|
111
|
+
# (including the marker-only fresh install) pass through.
|
|
112
|
+
return if @raw_hash.nil?
|
|
113
|
+
|
|
114
|
+
unknown = @raw_hash.keys.map(&:to_s).reject { |k| KNOWN_TOP_LEVEL_KEYS.include?(k) }
|
|
115
|
+
return if unknown.empty?
|
|
116
|
+
|
|
117
|
+
hint = ""
|
|
118
|
+
if unknown.size == 1
|
|
119
|
+
suggestion = DidYouMean::SpellChecker.new(dictionary: KNOWN_TOP_LEVEL_KEYS).correct(unknown.first).first
|
|
120
|
+
hint = " Did you mean '#{suggestion}:'?" if suggestion
|
|
95
121
|
end
|
|
122
|
+
|
|
123
|
+
raise MetadataError,
|
|
124
|
+
"Menu has unknown top-level key(s): #{unknown.map { |k| "'#{k}'" }.join(', ')}. " \
|
|
125
|
+
"Valid keys: #{KNOWN_TOP_LEVEL_KEYS.join(', ')}.#{hint}"
|
|
96
126
|
end
|
|
97
127
|
|
|
98
128
|
def log_auto_inject_sidebar_toggle
|
|
@@ -104,10 +104,13 @@ module LcpRuby
|
|
|
104
104
|
# Build a MenuItem from a parsed YAML hash.
|
|
105
105
|
# Detection priority: separator > view_group > children > url|presenter
|
|
106
106
|
# Existing call style preserved: `from_hash("view_group" => "x", ...)`
|
|
107
|
-
# works because the method takes a single positional Hash.
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
# `
|
|
107
|
+
# works because the method takes a single positional Hash. `type` is
|
|
108
|
+
# NOT an accepted key here — reject_unknown_keys! (in the shared
|
|
109
|
+
# build_from_hash) rejects it, matching the JSON schema, which has no
|
|
110
|
+
# `type` property. Type is always re-derived from the destination keys
|
|
111
|
+
# present. Provider returns that echo a `type:` reach build_from_hash via
|
|
112
|
+
# `from_hash_at_depth` (not this method), but LayoutHelper#filter_provider_keys
|
|
113
|
+
# strips it upstream (PROVIDER_STRIP_KEYS) so the round-trip still works.
|
|
111
114
|
def self.from_hash(hash)
|
|
112
115
|
build_from_hash(hash, 0)
|
|
113
116
|
end
|
|
@@ -127,6 +130,31 @@ module LcpRuby
|
|
|
127
130
|
# on top in PR #2b's MenuItem extensions.
|
|
128
131
|
DESTINATION_KEYS = %w[url presenter view_group provider children separator sidebar_toggle].freeze
|
|
129
132
|
|
|
133
|
+
# Every key the MenuItem parser knows about. Anything outside this
|
|
134
|
+
# set is rejected at boot — the schema also has `additionalProperties:
|
|
135
|
+
# false` on menu_item but that runs only via `lcp_ruby:validate`, not
|
|
136
|
+
# at `rails s` time. Parse-time rejection turns silent typos
|
|
137
|
+
# (`section: "Marketplace"` instead of `label:`) into immediate boot
|
|
138
|
+
# errors rather than icon-only menu items the configurator hunts for
|
|
139
|
+
# an hour. Keys starting with `_` are reserved for internal DSL
|
|
140
|
+
# annotations (`_label_source_loc`, `_aria_label_source_loc`) and
|
|
141
|
+
# allowed without enumeration.
|
|
142
|
+
# NOTE: `type` is intentionally NOT here. It's an internal kwarg
|
|
143
|
+
# (derived from the destination key, round-tripped via `to_kwargs` →
|
|
144
|
+
# `new` at the Ruby level, never read from author YAML). Listing it
|
|
145
|
+
# would let `type:` pass this parse-time check while the JSON schema
|
|
146
|
+
# (`additionalProperties: false`, no `type` property) rejects it under
|
|
147
|
+
# `lcp_ruby:validate` — two different verdicts for the same file.
|
|
148
|
+
KNOWN_KEYS = %w[
|
|
149
|
+
url presenter view_group provider children separator sidebar_toggle widget
|
|
150
|
+
method alias action defaults
|
|
151
|
+
render render_panel panel_provider options
|
|
152
|
+
label label_key icon aria_label aria_label_key
|
|
153
|
+
visible_when disable_when position badge
|
|
154
|
+
responsive_priority pin hide_below show_below collapse_label_below
|
|
155
|
+
_label_source_loc _aria_label_source_loc
|
|
156
|
+
].freeze
|
|
157
|
+
|
|
130
158
|
# Keys allowed on a `:separator` item. Anything else is a configurator
|
|
131
159
|
# footgun — a separator with a `label:` is invisible (no element renders
|
|
132
160
|
# it); a separator with `disable_when:` makes no sense (nothing to
|
|
@@ -147,6 +175,8 @@ module LcpRuby
|
|
|
147
175
|
def self.build_from_hash(hash, depth)
|
|
148
176
|
hash = HashUtils.stringify_deep(hash)
|
|
149
177
|
|
|
178
|
+
reject_unknown_keys!(hash)
|
|
179
|
+
|
|
150
180
|
# Destination mutex (B1, simple form): exactly one of the
|
|
151
181
|
# destination keys may be present. (PR #2a doesn't parse
|
|
152
182
|
# `provider:` — schema rejects unknown keys via
|
|
@@ -338,6 +368,25 @@ module LcpRuby
|
|
|
338
368
|
end
|
|
339
369
|
private_class_method :build_from_hash
|
|
340
370
|
|
|
371
|
+
# Raise on any key not in KNOWN_KEYS (keys prefixed `_` pass through
|
|
372
|
+
# as internal DSL annotations). When a single unknown key fuzzy-
|
|
373
|
+
# matches a known one (e.g. `section:` → `label:`), include the
|
|
374
|
+
# suggestion in the message — those typos are the common case.
|
|
375
|
+
def self.reject_unknown_keys!(hash)
|
|
376
|
+
unknown = hash.keys.reject { |k| KNOWN_KEYS.include?(k) || k.to_s.start_with?("_") }
|
|
377
|
+
return if unknown.empty?
|
|
378
|
+
|
|
379
|
+
hint = ""
|
|
380
|
+
if unknown.size == 1
|
|
381
|
+
suggestion = DidYouMean::SpellChecker.new(dictionary: KNOWN_KEYS).correct(unknown.first).first
|
|
382
|
+
hint = " Did you mean '#{suggestion}:'?" if suggestion
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
raise MetadataError,
|
|
386
|
+
"Menu item has unknown key(s): #{unknown.map { |k| "'#{k}'" }.join(', ')}.#{hint}"
|
|
387
|
+
end
|
|
388
|
+
private_class_method :reject_unknown_keys!
|
|
389
|
+
|
|
341
390
|
# Resolve label with i18n support.
|
|
342
391
|
# Priority: explicit `label: false` short-circuit > label_key >
|
|
343
392
|
# explicit label > view_group's primary presenter resolved_label >
|
|
@@ -368,6 +368,15 @@ module LcpRuby
|
|
|
368
368
|
features = []
|
|
369
369
|
features << "enums" if enum_fields.any?(&:virtual?)
|
|
370
370
|
features << "service_accessors" if fields.any?(&:service_accessor?)
|
|
371
|
+
# Auto-register the AR :json attribute for json-backed fields. The
|
|
372
|
+
# regular Builder always runs JsonTypeApplicator, but the bind_to path
|
|
373
|
+
# skips the Builder — so without this a bind_to model's json column is
|
|
374
|
+
# left to adapter reflection, and MariaDB (which reports JSON as
|
|
375
|
+
# longtext) returns a raw String instead of a parsed Hash, breaking
|
|
376
|
+
# json_field service accessors and json_valid() inserts.
|
|
377
|
+
features << "json_types" if fields.any? { |f| !f.array? && f.column_type == LcpRuby.json_column_type }
|
|
378
|
+
# Likewise array fields, which are json-backed on non-PostgreSQL adapters.
|
|
379
|
+
features << "array_types" if fields.any?(&:array?)
|
|
371
380
|
# Auto-enable defaults so bind_to models get the same default-on-create
|
|
372
381
|
# semantics as dynamic LCP models. Without this, fields like
|
|
373
382
|
# `theme: enum, default: auto, source: json_field` on a bind_to model
|
|
@@ -2,7 +2,12 @@ module LcpRuby
|
|
|
2
2
|
module Metadata
|
|
3
3
|
class ZoneDefinition
|
|
4
4
|
VALID_AREAS = %w[main tabs sidebar below].freeze
|
|
5
|
-
VALID_WIDGET_TYPES = %w[kpi_card text list chart embed workflow_graph approval_status].freeze
|
|
5
|
+
VALID_WIDGET_TYPES = %w[kpi_card text rich_text markdown list chart embed workflow_graph approval_status].freeze
|
|
6
|
+
|
|
7
|
+
# i18n-string widget types — neither depends on a model scope, all
|
|
8
|
+
# require `content_key`, none are valid targets for `depends_on`
|
|
9
|
+
# (no data dependency to refresh on).
|
|
10
|
+
I18N_TEXT_WIDGET_TYPES = %w[text rich_text markdown].freeze
|
|
6
11
|
VALID_CHART_TYPES = %w[line bar column pie donut area].freeze
|
|
7
12
|
PIE_CHART_TYPES = %w[pie donut].freeze
|
|
8
13
|
VALID_CHART_LEGEND_STRINGS = %w[auto top bottom left right].freeze
|
|
@@ -281,8 +286,10 @@ module LcpRuby
|
|
|
281
286
|
raise MetadataError, "Zone '#{@name}' kpi_card widget requires 'model'" unless @widget["model"]
|
|
282
287
|
raise MetadataError, "Zone '#{@name}' kpi_card widget requires 'aggregate'" unless @widget["aggregate"]
|
|
283
288
|
end
|
|
284
|
-
when "text"
|
|
285
|
-
|
|
289
|
+
when "text", "rich_text", "markdown"
|
|
290
|
+
unless @widget["content_key"]
|
|
291
|
+
raise MetadataError, "Zone '#{@name}' #{widget_type} widget requires 'content_key'"
|
|
292
|
+
end
|
|
286
293
|
when "list"
|
|
287
294
|
raise MetadataError, "Zone '#{@name}' list widget requires 'model'" unless @widget["model"]
|
|
288
295
|
when "chart"
|
|
@@ -372,8 +379,8 @@ module LcpRuby
|
|
|
372
379
|
def validate_depends_on!
|
|
373
380
|
return unless @depends_on
|
|
374
381
|
|
|
375
|
-
if widget? && @widget && @widget["type"]
|
|
376
|
-
raise MetadataError, "Zone '#{@name}': depends_on is not valid on
|
|
382
|
+
if widget? && @widget && I18N_TEXT_WIDGET_TYPES.include?(@widget["type"])
|
|
383
|
+
raise MetadataError, "Zone '#{@name}': depends_on is not valid on #{@widget["type"]} widget zones"
|
|
377
384
|
end
|
|
378
385
|
end
|
|
379
386
|
|
|
@@ -18,6 +18,11 @@ module LcpRuby
|
|
|
18
18
|
|
|
19
19
|
if LcpRuby.postgresql?
|
|
20
20
|
"#{quoted_table}.#{quoted_column} ->> #{conn.quote(key)}"
|
|
21
|
+
elsif LcpRuby.mysql?
|
|
22
|
+
# MySQL/MariaDB JSON_EXTRACT keeps JSON quotes around scalars;
|
|
23
|
+
# JSON_UNQUOTE strips them so comparisons match the raw value. SQLite's
|
|
24
|
+
# json_extract already returns the bare scalar (no JSON_UNQUOTE there).
|
|
25
|
+
"JSON_UNQUOTE(JSON_EXTRACT(#{quoted_table}.#{quoted_column}, #{conn.quote("$.#{key}")}))"
|
|
21
26
|
else
|
|
22
27
|
"JSON_EXTRACT(#{quoted_table}.#{quoted_column}, #{conn.quote("$.#{key}")})"
|
|
23
28
|
end
|
|
@@ -12,6 +12,7 @@ module LcpRuby
|
|
|
12
12
|
apply_table_name(model_class)
|
|
13
13
|
apply_enums(model_class)
|
|
14
14
|
apply_array_types(model_class)
|
|
15
|
+
apply_json_types(model_class)
|
|
15
16
|
apply_validations(model_class)
|
|
16
17
|
apply_inherited_parent_validator(model_class)
|
|
17
18
|
apply_transforms(model_class)
|
|
@@ -85,6 +86,10 @@ module LcpRuby
|
|
|
85
86
|
ArrayTypeApplicator.new(model_class, model_definition).apply!
|
|
86
87
|
end
|
|
87
88
|
|
|
89
|
+
def apply_json_types(model_class)
|
|
90
|
+
JsonTypeApplicator.new(model_class, model_definition).apply!
|
|
91
|
+
end
|
|
92
|
+
|
|
88
93
|
def apply_validations(model_class)
|
|
89
94
|
ValidationApplicator.new(model_class, model_definition).apply!
|
|
90
95
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module LcpRuby
|
|
2
|
+
module ModelFactory
|
|
3
|
+
# Registers an explicit ActiveRecord `:json` attribute type for json-backed
|
|
4
|
+
# fields.
|
|
5
|
+
#
|
|
6
|
+
# PostgreSQL (jsonb) and SQLite (json) are reflected correctly by their
|
|
7
|
+
# adapters, but MariaDB reports JSON columns as `longtext` — so without an
|
|
8
|
+
# explicit type ActiveRecord treats the column as text and serializes a Hash
|
|
9
|
+
# via Ruby's `Hash#to_s` (`{"k"=>"v"}`). That is invalid JSON and violates
|
|
10
|
+
# MariaDB's implicit `json_valid()` CHECK constraint on insert. Declaring the
|
|
11
|
+
# attribute type makes Hash<->JSON (de)serialization adapter-agnostic.
|
|
12
|
+
#
|
|
13
|
+
# Array fields are also json-backed on non-PostgreSQL adapters, but
|
|
14
|
+
# ArrayTypeApplicator already registers their own ArrayType, so they are
|
|
15
|
+
# excluded here. The custom_data column (custom fields) is handled separately
|
|
16
|
+
# in CustomFields::Applicator since it is not part of `model_definition.fields`.
|
|
17
|
+
class JsonTypeApplicator
|
|
18
|
+
def initialize(model_class, model_definition)
|
|
19
|
+
@model_class = model_class
|
|
20
|
+
@model_definition = model_definition
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def apply!
|
|
24
|
+
@model_definition.fields.each do |field|
|
|
25
|
+
next if field.array?
|
|
26
|
+
next unless field.column_type == LcpRuby.json_column_type
|
|
27
|
+
|
|
28
|
+
options = {}
|
|
29
|
+
options[:default] = field.default unless field.default.nil?
|
|
30
|
+
@model_class.attribute field.name.to_sym, :json, **options
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -55,26 +55,10 @@ module LcpRuby
|
|
|
55
55
|
|
|
56
56
|
# Walks the dot-path through metadata to locate the terminal field
|
|
57
57
|
# definition. Returns [FieldDefinition, model_name] tuple, or
|
|
58
|
-
# [nil, nil] when any segment cannot be resolved
|
|
59
|
-
#
|
|
60
|
-
# as "no enum routing — pass value through unchanged".
|
|
58
|
+
# [nil, nil] when any segment cannot be resolved. Callers treat
|
|
59
|
+
# [nil, nil] as "no enum routing — pass value through unchanged".
|
|
61
60
|
def resolve_terminal(model_def, parts)
|
|
62
|
-
|
|
63
|
-
return [ nil, nil ] if parts.empty?
|
|
64
|
-
|
|
65
|
-
current_def = model_def
|
|
66
|
-
parts[0..-2].each do |segment|
|
|
67
|
-
assoc = current_def.associations.find { |a| a.name == segment.to_s }
|
|
68
|
-
return [ nil, nil ] unless assoc&.singular? && assoc.lcp_model?
|
|
69
|
-
|
|
70
|
-
current_def = LcpRuby.loader.model_definition(assoc.target_model)
|
|
71
|
-
return [ nil, nil ] if current_def.nil?
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
terminal = parts.last.to_s
|
|
75
|
-
[ current_def.field(terminal), current_def.name ]
|
|
76
|
-
rescue MetadataError
|
|
77
|
-
[ nil, nil ]
|
|
61
|
+
Metadata::FieldPath.terminal(model_def, parts.join("."))
|
|
78
62
|
end
|
|
79
63
|
end
|
|
80
64
|
end
|
|
@@ -10,6 +10,13 @@ module LcpRuby
|
|
|
10
10
|
MANAGED_APPLY_MUTEX = Mutex.new
|
|
11
11
|
private_constant :MANAGED_APPLY_MUTEX
|
|
12
12
|
|
|
13
|
+
# Default precision/scale for a `decimal` field that declares neither
|
|
14
|
+
# (via type nor field column_options). Without this MySQL/MariaDB
|
|
15
|
+
# truncates to DECIMAL(10,0). 16 total digits with 4 fractional covers
|
|
16
|
+
# money and rate values with headroom; authors override per field/type.
|
|
17
|
+
DEFAULT_DECIMAL_PRECISION = 16
|
|
18
|
+
DEFAULT_DECIMAL_SCALE = 4
|
|
19
|
+
|
|
13
20
|
attr_reader :model_definition
|
|
14
21
|
|
|
15
22
|
# `mode` is :full (default) for dynamic models — runs create_table!,
|
|
@@ -637,6 +644,18 @@ module LcpRuby
|
|
|
637
644
|
options[:precision] = col_opts[:precision] if col_opts[:precision]
|
|
638
645
|
options[:scale] = col_opts[:scale] if col_opts[:scale]
|
|
639
646
|
options[:null] = col_opts[:null] if col_opts.key?(:null)
|
|
647
|
+
|
|
648
|
+
# A bare `decimal` truncates on MySQL/MariaDB: their DECIMAL defaults to
|
|
649
|
+
# (10,0) (scale 0), so 19.99 stores as 19. PostgreSQL (unlimited numeric)
|
|
650
|
+
# and SQLite keep the value, so only MySQL needs a default — scope it there
|
|
651
|
+
# to avoid narrowing PG's unlimited precision. Authors override via type or
|
|
652
|
+
# field column_options (e.g. the `currency` type's 12,2); a scale-only
|
|
653
|
+
# override is preserved (fill just the missing precision).
|
|
654
|
+
if field.column_type == :decimal && LcpRuby.mysql? && !options.key?(:precision)
|
|
655
|
+
options[:precision] = DEFAULT_DECIMAL_PRECISION
|
|
656
|
+
options[:scale] = DEFAULT_DECIMAL_SCALE unless options.key?(:scale)
|
|
657
|
+
end
|
|
658
|
+
|
|
640
659
|
if field.array?
|
|
641
660
|
if LcpRuby.postgresql?
|
|
642
661
|
options[:array] = true
|
|
@@ -16,6 +16,19 @@ module LcpRuby
|
|
|
16
16
|
errors_list << "missing 'name'" unless hash["name"].present?
|
|
17
17
|
errors_list << "missing 'zones' or empty zones array" unless hash["zones"].is_a?(Array) && hash["zones"].any?
|
|
18
18
|
|
|
19
|
+
# Security: the rich_text widget renders its content via `raw()`
|
|
20
|
+
# (unsanitized HTML). That is acceptable for YAML-authored pages
|
|
21
|
+
# (code-reviewed, deployed with the app), but a DB-defined page is
|
|
22
|
+
# end-user-editable through the platform UI — an attacker-controlled
|
|
23
|
+
# `content_key` would round-trip through `I18n.t(..., default:)` and
|
|
24
|
+
# render verbatim, a stored-XSS vector. Forbid it here; the markdown
|
|
25
|
+
# widget (sanitized) covers the same authoring need.
|
|
26
|
+
if hash["zones"].is_a?(Array) &&
|
|
27
|
+
hash["zones"].any? { |z| z.is_a?(Hash) && z["widget"].is_a?(Hash) && z["widget"]["type"] == "rich_text" }
|
|
28
|
+
errors_list << "rich_text widget is not allowed in a database-defined page " \
|
|
29
|
+
"(it renders unsanitized HTML); use the markdown widget instead"
|
|
30
|
+
end
|
|
31
|
+
|
|
19
32
|
if errors_list.empty?
|
|
20
33
|
begin
|
|
21
34
|
LcpRuby::Metadata::PageDefinition.from_hash(hash)
|