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.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/lcp-custom-field/SKILL.md +10 -1
  3. data/.claude/skills/lcp-custom-field/agents/openai.yaml +4 -0
  4. data/.claude/skills/lcp-getting-started/SKILL.md +1 -1
  5. data/.claude/skills/lcp-getting-started/agents/openai.yaml +4 -0
  6. data/.claude/skills/lcp-host-binding/agents/openai.yaml +4 -0
  7. data/.claude/skills/lcp-model/agents/openai.yaml +4 -0
  8. data/.claude/skills/lcp-permissions/agents/openai.yaml +4 -0
  9. data/.claude/skills/lcp-presenter/agents/openai.yaml +4 -0
  10. data/.claude/skills/lcp-workflow/SKILL.md +10 -2
  11. data/.claude/skills/lcp-workflow/agents/openai.yaml +4 -0
  12. data/CHANGELOG.md +59 -1
  13. data/app/assets/javascripts/lcp_ruby/dev_toolbar.js +5 -6
  14. data/app/controllers/concerns/lcp_ruby/zone_resolution.rb +14 -1
  15. data/app/helpers/lcp_ruby/display/card_helper.rb +71 -16
  16. data/app/helpers/lcp_ruby/display_helper.rb +30 -3
  17. data/app/helpers/lcp_ruby/display_template_helper.rb +3 -3
  18. data/app/helpers/lcp_ruby/layout_helper.rb +49 -8
  19. data/app/helpers/lcp_ruby/tree_helper.rb +24 -3
  20. data/app/models/lcp_ruby/user.rb +10 -0
  21. data/app/views/layouts/lcp_ruby/application.html.erb +42 -20
  22. data/app/views/layouts/lcp_ruby/auth.html.erb +6 -1
  23. data/app/views/lcp_ruby/resources/_show_sections.html.erb +24 -17
  24. data/app/views/lcp_ruby/resources/_table_index.html.erb +12 -17
  25. data/app/views/lcp_ruby/widgets/_markdown.html.erb +13 -0
  26. data/app/views/lcp_ruby/widgets/_presenter_zone.html.erb +23 -7
  27. data/app/views/lcp_ruby/widgets/_rich_text.html.erb +25 -0
  28. data/docs/README.md +3 -0
  29. data/docs/feature-catalog.md +5 -2
  30. data/docs/feature-catalog.yml +89 -9
  31. data/docs/getting-started.md +38 -22
  32. data/docs/guides/dashboards.md +44 -1
  33. data/docs/guides/display-types.md +6 -0
  34. data/docs/guides/host-application.md +1 -1
  35. data/docs/guides/windows-setup.md +211 -0
  36. data/docs/guides/workflow.md +1 -1
  37. data/docs/reference/asset-pipeline.md +79 -0
  38. data/docs/reference/pages.md +50 -1
  39. data/docs/reference/presenters.md +6 -0
  40. data/docs/reference/workflow.md +2 -2
  41. data/examples/crm/Gemfile +3 -0
  42. data/examples/crm/Gemfile.lock +14 -9
  43. data/examples/crm/app/assets/config/manifest.js +4 -0
  44. data/examples/hr/Gemfile +3 -0
  45. data/examples/hr/Gemfile.lock +14 -9
  46. data/examples/hr/app/assets/config/manifest.js +4 -0
  47. data/examples/showcase/Gemfile +3 -0
  48. data/examples/showcase/Gemfile.lock +12 -9
  49. data/examples/showcase/app/assets/config/manifest.js +4 -0
  50. data/examples/showcase/config/lcp_ruby/pages/main_dashboard.yml +17 -2
  51. data/examples/showcase/config/locales/cs.yml +8 -0
  52. data/examples/showcase/config/locales/en.yml +8 -0
  53. data/examples/todo/Gemfile +3 -0
  54. data/examples/todo/Gemfile.lock +14 -9
  55. data/examples/todo/app/assets/config/manifest.js +4 -0
  56. data/exe/lcp +1 -1
  57. data/lib/generators/lcp_ruby/agent_setup_generator.rb +12 -9
  58. data/lib/generators/lcp_ruby/claude_skills_generator.rb +23 -19
  59. data/lib/generators/lcp_ruby/install_generator.rb +171 -3
  60. data/lib/generators/lcp_ruby/templates/agent_setup/agents_md.md +2 -1
  61. data/lib/generators/lcp_ruby/templates/agent_setup/claude_md.md +3 -2
  62. data/lib/generators/lcp_ruby/templates/install/menu.yml.tt +4 -0
  63. data/lib/generators/lcp_ruby/templates/monitoring/page.yml +5 -0
  64. data/lib/lcp_ruby/app_template.rb +37 -1
  65. data/lib/lcp_ruby/array_query.rb +33 -10
  66. data/lib/lcp_ruby/cli/new_command.rb +9 -4
  67. data/lib/lcp_ruby/cli/run_command.rb +98 -11
  68. data/lib/lcp_ruby/cli/skills_command.rb +27 -15
  69. data/lib/lcp_ruby/cli.rb +5 -1
  70. data/lib/lcp_ruby/configuration.rb +13 -2
  71. data/lib/lcp_ruby/custom_fields/applicator.rb +8 -0
  72. data/lib/lcp_ruby/custom_fields/query.rb +28 -20
  73. data/lib/lcp_ruby/display/markdown_sanitize.rb +36 -0
  74. data/lib/lcp_ruby/display/renderers/markdown.rb +6 -13
  75. data/lib/lcp_ruby/engine.rb +185 -1
  76. data/lib/lcp_ruby/export/data_generator.rb +5 -1
  77. data/lib/lcp_ruby/export/value_formatter.rb +34 -0
  78. data/lib/lcp_ruby/grouped_query/builder.rb +21 -0
  79. data/lib/lcp_ruby/metadata/configuration_validator.rb +5 -5
  80. data/lib/lcp_ruby/metadata/field_path.rb +57 -0
  81. data/lib/lcp_ruby/metadata/loader.rb +33 -1
  82. data/lib/lcp_ruby/metadata/menu_definition.rb +32 -2
  83. data/lib/lcp_ruby/metadata/menu_item.rb +53 -4
  84. data/lib/lcp_ruby/metadata/model_definition.rb +9 -0
  85. data/lib/lcp_ruby/metadata/zone_definition.rb +12 -5
  86. data/lib/lcp_ruby/metrics/json_query.rb +5 -0
  87. data/lib/lcp_ruby/model_factory/builder.rb +5 -0
  88. data/lib/lcp_ruby/model_factory/json_type_applicator.rb +35 -0
  89. data/lib/lcp_ruby/model_factory/label_method_builder.rb +3 -19
  90. data/lib/lcp_ruby/model_factory/schema_manager.rb +19 -0
  91. data/lib/lcp_ruby/pages/definition_validator.rb +13 -0
  92. data/lib/lcp_ruby/presenter/field_value_resolver.rb +36 -19
  93. data/lib/lcp_ruby/schemas/page.json +33 -1
  94. data/lib/lcp_ruby/search/custom_field_filter.rb +5 -0
  95. data/lib/lcp_ruby/skills_installer.rb +15 -2
  96. data/lib/lcp_ruby/tasks/doctor.rb +13 -8
  97. data/lib/lcp_ruby/version.rb +1 -1
  98. data/lib/lcp_ruby/widgets/data_resolver.rb +42 -1
  99. data/lib/lcp_ruby/widgets/date_grouper.rb +19 -0
  100. data/lib/lcp_ruby/workflow/contract_validator.rb +1 -1
  101. data/lib/lcp_ruby/workflow/model_source.rb +2 -2
  102. data/lib/lcp_ruby.rb +18 -0
  103. data/lib/tasks/lcp_ruby_db.rake +8 -1
  104. data/lib/tasks/lcp_ruby_i18n_check.rake +8 -0
  105. metadata +34 -24
  106. data/examples/crm/erd.md +0 -163
  107. data/examples/hr/erd.md +0 -396
  108. data/examples/showcase/erd.md +0 -567
  109. data/examples/todo/erd.md +0 -21
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15ce78e55a51f7b9192bd168c209459194465fe62de1f459d405d126e937accf
4
- data.tar.gz: 550348550e26fc527d98d4090038f2919e2b1d866de92ecfaf885f75b2a597f1
3
+ metadata.gz: 9484b30d4253443cf9411e7e3bf6ae68446a092329b9f15b755db3fff166f0b0
4
+ data.tar.gz: 17e6a672a34b528006c55bf851e60f6ddf303df10acd15f4370067e3231240f7
5
5
  SHA512:
6
- metadata.gz: 9e16d8a1b1d2804f52bd42483ddde70819511994465bcf68e58b27eee62234ad4f4e3155ae7b2478381cc4a3c004eeb3d04d68fe3c611ae9317198d64b5ae620
7
- data.tar.gz: 75b0a589c1ee4e84c41c05fd78c356e79212d68f048d1d57e1c2857ce350ba36adf0701f7d9dc88ae29ff5867eb0eeccb33a3aee1d15a01e0ca10ff3ef6dc0cd
6
+ metadata.gz: bbf59bd655c458f3dbbb9ea01e6ac39e4389738fd38311851ffdf08eb02c19a045268acff0b4daca2da375a39dc197b1d029932771952d2b5bc52afaf2dd6dd8
7
+ data.tar.gz: 23f062bd96b24754437dae622770d8f73f26f344018488cbdb68b60e4734888fdc06bde98dab314dd684e6f277aa8cd1c22f36407b12dfdf42e563833c804ef4
@@ -1,6 +1,15 @@
1
1
  ---
2
2
  name: lcp-custom-field
3
- description: Use when enabling or managing LCP Ruby custom fields — runtime user-defined fields stored in a model's `custom_data` JSON column. Triggers on phrases like "custom fields", "user-defined fields", "runtime fields", "extensible fields", "add fields without migration", "custom_data", "custom_field_definition", "field at runtime", "tenant-specific fields", "EAV", "show_in_table", "show_in_form", "field permissions for custom fields", `custom_fields: true` on a model. Also for editing the auto-generated `custom_field_definition` model/presenter/permissions or rolling out the `lcp_ruby:custom_fields` generator.
3
+ description: >-
4
+ Use when enabling or managing LCP Ruby custom fields — runtime user-defined
5
+ fields stored in a model's `custom_data` JSON column. Triggers on phrases like
6
+ "custom fields", "user-defined fields", "runtime fields", "extensible fields",
7
+ "add fields without migration", "custom_data", "custom_field_definition",
8
+ "field at runtime", "tenant-specific fields", "EAV", "show_in_table",
9
+ "show_in_form", "field permissions for custom fields", `custom_fields: true`
10
+ on a model. Also for editing the auto-generated `custom_field_definition`
11
+ model/presenter/permissions or rolling out the `lcp_ruby:custom_fields`
12
+ generator.
4
13
  ---
5
14
 
6
15
  > **Reference docs base:** run `bundle exec rake lcp_ruby:docs` in an LCP app (version-matched), otherwise `lcp docs path`. Resolve any `docs/X` reference below under that base (or the project root if a `docs/` tree exists there).
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "LCP Custom Fields"
3
+ short_description: "Runtime custom fields for LCP models"
4
+ default_prompt: "Use $lcp-custom-field to add runtime custom fields to an LCP Ruby model."
@@ -22,7 +22,7 @@ Pick the path based on whether the user has an app yet.
22
22
 
23
23
  ### Path A — greenfield (`lcp new`)
24
24
 
25
- Requires Ruby 3.1+ and Rails 8.1+ on the user's machine. The CLI runs `rails new`, installs the gem, runs the chosen generators, prepares the database, and copies `lcp-*` Claude skills into `.claude/skills/`.
25
+ Requires Ruby 3.2+ and Rails 8.1+ on the user's machine. The CLI runs `rails new`, installs the gem, runs the chosen generators, prepares the database, and copies `lcp-*` Claude skills into `.claude/skills/`.
26
26
 
27
27
  ```bash
28
28
  lcp new my_app # interactive wizard
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "LCP Getting Started"
3
+ short_description: "Bootstrap and orient LCP Ruby apps"
4
+ default_prompt: "Use $lcp-getting-started to bootstrap a new LCP Ruby app and pick the first authoring steps."
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "LCP Host Binding"
3
+ short_description: "Bind LCP metadata to Rails host apps"
4
+ default_prompt: "Use $lcp-host-binding to connect LCP metadata to existing Rails models or controllers."
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "LCP Model Authoring"
3
+ short_description: "Create and edit LCP model metadata"
4
+ default_prompt: "Use $lcp-model to create or update LCP Ruby model metadata."
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "LCP Permissions"
3
+ short_description: "Author LCP role and field access rules"
4
+ default_prompt: "Use $lcp-permissions to define LCP role, CRUD, field, and record access rules."
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "LCP Presenter Authoring"
3
+ short_description: "Build LCP index, show, and form UIs"
4
+ default_prompt: "Use $lcp-presenter to build or refine an LCP presenter UI."
@@ -1,6 +1,14 @@
1
1
  ---
2
2
  name: lcp-workflow
3
- description: Use when creating or modifying LCP Ruby workflow definitions in `config/lcp_ruby/workflows/` (either YAML `*.yml` or DSL `*.rb`), including approval processes. Triggers on phrases like "add a workflow", "state machine", "transition", "approval process", "approver", "approve/reject task", "guard condition", "set_fields on transition", "audit log for state changes", "workflow timeline", "delegation", "rework policy", "bypass_when", "on_entry/on_exit", "trigger: system", "workflow_source", "host workflow provider".
3
+ description: >-
4
+ Use when creating or modifying LCP Ruby workflow definitions in
5
+ `config/lcp_ruby/workflows/` (either YAML `*.yml` or DSL `*.rb`), including
6
+ approval processes. Triggers on phrases like "add a workflow", "state
7
+ machine", "transition", "approval process", "approver", "approve/reject task",
8
+ "guard condition", "set_fields on transition", "audit log for state changes",
9
+ "workflow timeline", "delegation", "rework policy", "bypass_when",
10
+ "on_entry/on_exit", "trigger: system", "workflow_source", "host workflow
11
+ provider".
4
12
  ---
5
13
 
6
14
  > **Reference docs base:** run `bundle exec rake lcp_ruby:docs` in an LCP app (version-matched), otherwise `lcp docs path`. Resolve any `docs/X` reference below under that base (or the project root if a `docs/` tree exists there).
@@ -207,7 +215,7 @@ end
207
215
  | Source | Behavior |
208
216
  |--------|----------|
209
217
  | `:static` | YAML/DSL files in `config/lcp_ruby/workflows/`. Always loaded if files exist. |
210
- | `:model` | DB-backed. Requires a model with `name`, `model_name`, `field_name`, `states` (json), `transitions` (json), `version`, `active`, `audit_log`. |
218
+ | `:model` | DB-backed. Requires a model with `name`, `target_model`, `field_name`, `states` (json), `transitions` (json), `version`, `active`, `audit_log`. |
211
219
  | `:host` | Provider class implementing `StateMachineContract` (`workflows_for(model_name)`, `workflow_by_name(name)`). |
212
220
 
213
221
  **Static is always active.** The configured non-static source is additive. **On name conflicts: `static > model > host`.**
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "LCP Workflow Authoring"
3
+ short_description: "Define LCP state machines and approvals"
4
+ default_prompt: "Use $lcp-workflow to define an LCP workflow state machine or approval process."
data/CHANGELOG.md CHANGED
@@ -9,6 +9,63 @@ warnings until `1.0.0`.
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.2.0] - 2026-06-01
13
+
14
+ ### Added
15
+
16
+ - **MySQL / MariaDB support.** The full non-system suite now passes on
17
+ MySQL/MariaDB and is gated in CI alongside PostgreSQL and SQLite. Adapter
18
+ -aware SQL throughout: date-grouping emits `DATE_FORMAT`/`CONCAT`+`QUARTER`
19
+ period keys, custom-field JSON queries wrap `JSON_EXTRACT` in `JSON_UNQUOTE`
20
+ with MySQL-valid `CAST` targets, `ArrayQuery` uses a `JSON_TABLE()` row source
21
+ (contains/overlaps/contained_by/text-search were previously broken on MySQL),
22
+ and `json`/`array` attribute types are auto-applied on `bind_to` models. New
23
+ `LcpRuby.mysql?` helper; MySQL-only default decimal precision (16,4) when a
24
+ field declares none.
25
+ - **Windows support.** Example apps bundle `tzinfo-data` (locks made
26
+ Windows-installable via `--add-platform x64-mingw-ucrt`); Sprockets'
27
+ file-backed asset cache is disabled on Windows in non-production to dodge a
28
+ non-atomic `File.rename` EACCES race; the `rails --version` probe is portable
29
+ on cmd.exe so `lcp new` / `lcp run` work. New
30
+ [docs/guides/windows-setup.md](docs/guides/windows-setup.md).
31
+ - **Asset-pipeline auto-wiring.** Sprockets/Propshaft engine assets are wired
32
+ up automatically with a boot-time check, opt-out via `--skip-asset-pipeline`
33
+ on both layouts. `rich_text` and `markdown` page widgets; generator
34
+ `DELETE ME` sample with `--skip-sample` opt-out.
35
+
36
+ ### Changed
37
+
38
+ - **Minimum Ruby is now 3.2** (was 3.1). `lib/` already uses `Data.define`
39
+ (Ruby 3.2), so the gem would `NameError` on 3.1 — the declared floor was
40
+ wrong. Ruby 3.1 is also EOL (security support ended March 2025).
41
+ - **AI authoring skills now install for Codex as well as Claude Code.**
42
+ `lcp new`, `rails generate lcp_ruby:agent_setup`, and
43
+ the skills-only `rails generate lcp_ruby:claude_skills` /
44
+ `lcp skills install` paths populate `.codex/skills/lcp-*` alongside
45
+ `.claude/skills/lcp-*`; shipped skills also include `agents/openai.yaml`
46
+ metadata for Codex/OpenAI clients.
47
+ - Dropped the unused `view_component` dependency; bumped the CSRF-protection
48
+ gem and declared `csv` + `ostruct` explicitly (silences Ruby 3.4 default-gem
49
+ deprecation warnings).
50
+ - Enum resolver returns raw keys and humanizes at display time, consistently
51
+ across all surfaces.
52
+
53
+ ### Fixed
54
+
55
+ - `lcp run` is now idempotent on a re-used directory: a `.lcp-run` marker lets a
56
+ second invocation re-boot the already-materialized example instead of aborting
57
+ with "already exists and is not empty". A non-empty directory without the
58
+ marker is still refused, and the marker is written only after `db:setup`
59
+ succeeds so an interrupted first run can't downgrade the next.
60
+ - `bind_to` models on MariaDB serialized `json`/`array` columns via `Hash#to_s`
61
+ (the bind_to path skips the Builder, so AR attribute types were never applied),
62
+ tripping `json_valid()`. Built-in auth + OIDC also fixed (`LcpRuby::User` now
63
+ declares explicit `:json` attributes for `lcp_role`/`profile_data`).
64
+ - Usage-audit hardening (from building a real host app): menu key validation and
65
+ empty-menu guards, export `Array` handling for `has_many` dot-paths,
66
+ render-time `rich_text` sanitization on DB-backed pages, manifest
67
+ failure-mode detection, and `i18n_check` rake fixes.
68
+
12
69
  ## [0.1.0] - 2026-05-28
13
70
 
14
71
  Initial public release.
@@ -65,5 +122,6 @@ Initial public release.
65
122
  - Minimum **Ruby 3.1**, **Rails ≥ 7.1, < 9.0**.
66
123
  - The CLI binary is `lcp`; the Ruby module is `LcpRuby`.
67
124
 
68
- [Unreleased]: https://github.com/lksv/lcp/compare/v0.1.0...HEAD
125
+ [Unreleased]: https://github.com/lksv/lcp/compare/v0.2.0...HEAD
126
+ [0.2.0]: https://github.com/lksv/lcp/compare/v0.1.0...v0.2.0
69
127
  [0.1.0]: https://github.com/lksv/lcp/releases/tag/v0.1.0
@@ -5,12 +5,11 @@
5
5
  var BUTTON_CLASS = "lcp-dev-button";
6
6
  var SELECTOR = "[data-lcp-dev-presenter], [data-lcp-dev-zone]";
7
7
 
8
- // Defensive i18n shim: window.LcpI18n is populated by lcp_i18n_payload_tag
9
- // + i18n.js (bundled into application.js). Both live behind
10
- // `if Rails.application.config.respond_to?(:assets)` in the layout, while
11
- // dev_toolbar.js loads unconditionally whenever DevToolbar is enabled.
12
- // In hosts without Sprockets (Propshaft-only, no-asset setups) LcpI18n.t
13
- // is undefined, so we fall back to the caller-provided fallback string.
8
+ // Defensive i18n shim: window.LcpI18n.t comes from i18n.js (bundled into
9
+ // application.js). application.js and this dev_toolbar.js now load together
10
+ // behind `lcp_emit_engine_assets?` in the layout, so LcpI18n.t is normally
11
+ // present when this runs. The shim stays as defense-in-depth: if LcpI18n.t
12
+ // is ever undefined we fall back to the caller-provided fallback string.
14
13
  function t(key, fallback) {
15
14
  var i18n = window.LcpI18n;
16
15
  if (i18n && typeof i18n.t === "function") return i18n.t(key, fallback);
@@ -11,7 +11,8 @@ module LcpRuby
11
11
 
12
12
  if zone.widget?
13
13
  resolved_context = resolve_zone_scope_context(zone)
14
- Widgets::DataResolver.new(zone, user: effective_user, scope_context: resolved_context, record: @record, **page_params_opts).resolve
14
+ Widgets::DataResolver.new(zone, user: effective_user, scope_context: resolved_context,
15
+ record: @record, trusted: page_allows_raw_html?, **page_params_opts).resolve
15
16
  elsif zone.custom?
16
17
  resolve_custom_zone_data(zone)
17
18
  elsif zone.record_source?
@@ -29,6 +30,18 @@ module LcpRuby
29
30
  end
30
31
  end
31
32
 
33
+ # Whether the page being rendered may emit raw (unsanitized) HTML widgets
34
+ # (rich_text). True unless the page is database-defined — DB pages are
35
+ # end-user-editable through the platform UI, so their rich_text content is
36
+ # sanitized at render. Defense-in-depth behind the save-time
37
+ # Pages::DefinitionValidator, which bypass paths (seeds, update_column,
38
+ # save(validate: false), records persisted before that validator shipped)
39
+ # can skip.
40
+ def page_allows_raw_html?
41
+ page = @page_definition || current_page
42
+ page.nil? || page.source_type != "database"
43
+ end
44
+
32
45
  def resolve_zone_scope_context(zone)
33
46
  return {} if zone.scope_context.blank?
34
47
 
@@ -28,7 +28,13 @@ module LcpRuby
28
28
  return "" if field.blank?
29
29
 
30
30
  value = resolve_title_value(record, field)
31
- rendered = empty_value_placeholder(value, current_presenter)
31
+ # Humanize when title_field points to an enum (issue #18 — resolver
32
+ # now returns raw enum keys). Non-enum fields pass through unchanged;
33
+ # a has_many dot-path Array renders comma-joined via the collection
34
+ # renderer (card_display_value), same as subtitle/description — a bare
35
+ # format_enum_display would print the Ruby array literal.
36
+ fd, model_name = card_field_meta(field)
37
+ rendered = empty_value_placeholder(card_display_value(value, fd, model_name || current_model_name), current_presenter)
32
38
 
33
39
  # Blank/unauthorized title is the placeholder "—" — never a nav target.
34
40
  return rendered if value.blank?
@@ -55,14 +61,16 @@ module LcpRuby
55
61
  return "" unless @column_set.readable_named_slot?(field)
56
62
 
57
63
  value = @field_resolver.resolve(record, field, fk_map: @fk_map)
58
- field_def = card_field_def(field)
64
+ field_def, fd_model = card_field_meta(field)
65
+ fd_model ||= current_model_name
59
66
 
60
67
  rendered =
61
68
  if (renderer_key = card_config["subtitle_renderer"]).present?
62
- render_display_value(value, renderer_key, card_config["subtitle_options"] || {}, field_def, record: record)
69
+ render_display_value(value, renderer_key, card_config["subtitle_options"] || {}, field_def, record: record, model_name: fd_model)
63
70
  else
64
- # Parity with table column rendering — humanize enum values before placeholder fallback.
65
- empty_value_placeholder(format_enum_display(value, field_def, current_model_name), current_presenter)
71
+ # Parity with table column rendering — humanize enum values
72
+ # (scalar or has_many Array) before placeholder fallback.
73
+ empty_value_placeholder(card_display_value(value, field_def, fd_model), current_presenter)
66
74
  end
67
75
 
68
76
  content_tag(:div, rendered, class: "lcp-card-subtitle")
@@ -77,10 +85,16 @@ module LcpRuby
77
85
  value = @field_resolver.resolve(record, field, fk_map: @fk_map)
78
86
  return "" if value.blank?
79
87
 
88
+ # Humanize when description_field points to an enum (uncommon but
89
+ # supported). Non-enum fields pass through unchanged; has_many Arrays
90
+ # render comma-joined via the collection renderer.
91
+ fd, fd_model = card_field_meta(field)
92
+ display = card_display_value(value, fd, fd_model || current_model_name)
93
+
80
94
  max_lines = card_config["description_max_lines"]
81
95
  style = ("--lcp-card-desc-lines: #{max_lines.to_i}" if max_lines)
82
96
 
83
- content_tag(:div, value, class: "lcp-card-description", style: style)
97
+ content_tag(:div, display, class: "lcp-card-description", style: style)
84
98
  end
85
99
 
86
100
  # Returns: <div class="lcp-card-image"><img .../></div> or "".
@@ -140,8 +154,9 @@ module LcpRuby
140
154
 
141
155
  items = fields.map do |fc|
142
156
  value = @field_resolver.resolve(record, fc["field"], fk_map: @fk_map)
143
- field_def = card_field_def(fc["field"])
144
- label = field_label_for(fc, field_def: field_def, model_name: current_model_name)
157
+ field_def, fd_model = card_field_meta(fc["field"])
158
+ fd_model ||= current_model_name
159
+ label = field_label_for(fc, field_def: field_def, model_name: fd_model)
145
160
 
146
161
  rendered_value =
147
162
  if fc["partial"].present?
@@ -151,9 +166,9 @@ module LcpRuby
151
166
  context: "card.fields[].partial '#{fc['partial']}' for record_id=#{record.id}"
152
167
  )
153
168
  elsif fc["renderer"].present?
154
- render_display_value(value, fc["renderer"], fc["options"] || {}, field_def, record: record)
169
+ render_display_value(value, fc["renderer"], fc["options"] || {}, field_def, record: record, model_name: fd_model)
155
170
  else
156
- empty_value_placeholder(format_enum_display(value, field_def, current_model_name), current_presenter)
171
+ empty_value_placeholder(card_display_value(value, field_def, fd_model), current_presenter)
157
172
  end
158
173
 
159
174
  content_tag(:div, label, class: "lcp-card-field-label") +
@@ -241,20 +256,58 @@ module LcpRuby
241
256
 
242
257
  private
243
258
 
259
+ # Returns [FieldDefinition, model_name] tuple for any field reference
260
+ # (direct field or dot-path). Used to humanize enum values against the
261
+ # correct i18n namespace — the dot-path target model, not the page's
262
+ # current_model_name. Returns [nil, nil] for template fields, blanks,
263
+ # or unresolvable paths.
264
+ def card_field_meta(field_name)
265
+ return [ nil, nil ] if field_name.blank?
266
+ return [ nil, nil ] if Presenter::FieldValueResolver.template_field?(field_name)
267
+
268
+ # through_collections: has_many dot-path enum terminals (e.g.
269
+ # `tasks.status`) must resolve their field def so the Array of values
270
+ # humanizes element-wise (issue #18).
271
+ Metadata::FieldPath.terminal(current_model_definition, field_name, through_collections: true)
272
+ end
273
+
244
274
  def card_field_def(field_name)
245
- return nil if field_name.blank?
246
- return nil if Presenter::FieldValueResolver.dot_path?(field_name) || Presenter::FieldValueResolver.template_field?(field_name)
275
+ card_field_meta(field_name).first
276
+ end
277
+
278
+ # Humanize + render a card field value that may be a scalar or an Array
279
+ # (has_many dot-path terminal). Arrays render comma-joined through the
280
+ # collection renderer so enum elements show labels, not raw keys
281
+ # (issue #18 — `format_enum_display` returns an Array for Array input,
282
+ # which a bare `content_tag` would print as a Ruby literal). Scalars
283
+ # humanize via `format_enum_display`. Mirrors the table/tree surfaces.
284
+ def card_display_value(value, field_def, model_name)
285
+ return render_display_value(value, "collection", {}, field_def, model_name: model_name) if value.is_a?(Array)
286
+
287
+ format_enum_display(value, field_def, model_name)
288
+ end
247
289
 
248
- current_model_definition&.field(field_name)
290
+ # Plain-text humanized value for alt attributes. Unlike card_display_value
291
+ # (which emits HTML through the collection renderer), a has_many dot-path
292
+ # Array of enum labels is comma-joined as plain text — alt is an attribute,
293
+ # not markup, so HTML would leak into it. Issue #18 / card surface: a bare
294
+ # `format_enum_display(...).to_s` printed the Ruby array literal instead.
295
+ def card_alt_text(value, field_def, model_name)
296
+ humanized = format_enum_display(value, field_def, model_name)
297
+ humanized.is_a?(Array) ? humanized.join(", ") : humanized.to_s
249
298
  end
250
299
 
251
300
  def card_image_alt(record, card_config)
252
301
  if (alt_field = card_config["image_alt_field"]).present?
253
- return @field_resolver.resolve(record, alt_field, fk_map: @fk_map).to_s
302
+ value = @field_resolver.resolve(record, alt_field, fk_map: @fk_map)
303
+ fd, fd_model = card_field_meta(alt_field)
304
+ return card_alt_text(value, fd, fd_model || current_model_name)
254
305
  end
255
306
 
256
307
  if (title_field = card_config["title_field"]).present?
257
- return resolve_title_value(record, title_field).to_s
308
+ value = resolve_title_value(record, title_field)
309
+ fd, fd_model = card_field_meta(title_field)
310
+ return card_alt_text(value, fd, fd_model || current_model_name)
258
311
  end
259
312
 
260
313
  ""
@@ -279,7 +332,9 @@ module LcpRuby
279
332
  alt_field = card_config["avatar_alt_field"]
280
333
  return "" if alt_field.blank?
281
334
 
282
- @field_resolver.resolve(record, alt_field, fk_map: @fk_map).to_s
335
+ value = @field_resolver.resolve(record, alt_field, fk_map: @fk_map)
336
+ fd, fd_model = card_field_meta(alt_field)
337
+ card_alt_text(value, fd, fd_model || current_model_name)
283
338
  end
284
339
 
285
340
  def image_renderable?(value)
@@ -1,24 +1,51 @@
1
1
  module LcpRuby
2
2
  module DisplayHelper
3
- def render_display_value(value, renderer_key, options = {}, field_def = nil, record: nil)
3
+ def render_display_value(value, renderer_key, options = {}, field_def = nil, record: nil, model_name: nil)
4
4
  return value if renderer_key.blank?
5
5
 
6
6
  renderer = LcpRuby::Display::RendererRegistry.renderer_for(renderer_key.to_s)
7
7
  return value unless renderer
8
8
 
9
9
  effective_options = options || {}
10
- if field_def&.enum? && !effective_options.key?("label")
11
- effective_options = effective_options.merge("label" => field_def.enum_label_for(value, model_name: current_model_name))
10
+ if field_def&.enum?
11
+ # For dot-path columns the i18n namespace is the target model, not the
12
+ # presenter's current model. Caller passes model_name: explicitly when
13
+ # the field def comes from a different model than the page is showing.
14
+ enum_model = model_name || current_model_name
15
+ if value.is_a?(Array)
16
+ # has_many → terminal enum dot-path (e.g. `tasks.status`) resolves to
17
+ # an Array of raw keys; humanize each element so list renderers
18
+ # (`collection`) emit labels, not raw keys (issue #18). The scalar
19
+ # `label` injection below can't help here — list renderers render
20
+ # each element, not a single pre-labeled value.
21
+ value = humanize_enum_array(value, field_def, enum_model)
22
+ elsif !effective_options.key?("label")
23
+ effective_options = effective_options.merge("label" => field_def.enum_label_for(value, model_name: enum_model))
24
+ end
12
25
  end
13
26
 
14
27
  renderer.render(value, effective_options, record: record, view_context: self)
15
28
  end
16
29
 
30
+ # Humanize an enum value for display. Scalars route through
31
+ # `enum_label_for`; Arrays (has_many → terminal enum dot-path) humanize
32
+ # each element so the collection renderer / clipboard copy show labels,
33
+ # not raw keys (issue #18). Non-enum field defs and blank values pass
34
+ # through unchanged.
17
35
  def format_enum_display(value, field_def, model_name = nil)
18
36
  return value unless field_def&.enum? && value.present?
37
+ return humanize_enum_array(value, field_def, model_name) if value.is_a?(Array)
19
38
  field_def.enum_label_for(value, model_name: model_name)
20
39
  end
21
40
 
41
+ # Map each element of an enum-valued Array through `enum_label_for`,
42
+ # leaving blanks untouched. Shared by `render_display_value` and
43
+ # `format_enum_display` so on-table, in-card, and clipboard rendering
44
+ # of has_many enum dot-paths stay consistent.
45
+ def humanize_enum_array(values, field_def, model_name)
46
+ values.map { |v| v.blank? ? v : field_def.enum_label_for(v, model_name: model_name) }
47
+ end
48
+
22
49
  def compute_item_classes(record, presenter)
23
50
  rules = presenter.item_classes
24
51
  return "" if rules.empty?
@@ -40,7 +40,7 @@ module LcpRuby
40
40
  link_to_record, presenter_slug)
41
41
  resolver = build_template_field_resolver(model_definition, permission_evaluator)
42
42
 
43
- title_text = resolver ? resolver.resolve(record, template_def.template) : template_def.template
43
+ title_text = resolver ? resolver.resolve(record, template_def.template, humanize_enums: true) : template_def.template
44
44
  title_html = ERB::Util.html_escape(title_text.to_s)
45
45
  title_html = wrap_in_link(title_html, record, presenter_slug, model_definition) if link_to_record
46
46
 
@@ -53,13 +53,13 @@ module LcpRuby
53
53
  parts << content_tag(:span, title_html, class: "lcp-display-template__title")
54
54
 
55
55
  if template_def.subtitle.present?
56
- subtitle_text = resolver ? resolver.resolve(record, template_def.subtitle) : template_def.subtitle
56
+ subtitle_text = resolver ? resolver.resolve(record, template_def.subtitle, humanize_enums: true) : template_def.subtitle
57
57
  parts << content_tag(:span, ERB::Util.html_escape(subtitle_text.to_s),
58
58
  class: "lcp-display-template__subtitle")
59
59
  end
60
60
 
61
61
  if template_def.badge.present?
62
- badge_text = resolver ? resolver.resolve(record, template_def.badge) : template_def.badge
62
+ badge_text = resolver ? resolver.resolve(record, template_def.badge, humanize_enums: true) : template_def.badge
63
63
  parts << content_tag(:span, ERB::Util.html_escape(badge_text.to_s),
64
64
  class: "lcp-display-template__badge")
65
65
  end
@@ -1,5 +1,17 @@
1
1
  module LcpRuby
2
2
  module LayoutHelper
3
+ # Whether the engine should emit its own pipeline-dependent asset tags
4
+ # (`javascript_include_tag`/`stylesheet_link_tag`/`asset_path` for
5
+ # `lcp_ruby/*`). False when the host opted out via
6
+ # `config.skip_asset_pipeline_check = true` — that host runs its own JS
7
+ # bundler and owns including the LCP assets, so emitting the tags would
8
+ # raise Propshaft::MissingAssetError → 500. Single source of truth so the
9
+ # layout's several engine-asset blocks (core bundle, dev toolbar, vendored
10
+ # mermaid) can't drift — adding a new one just calls this.
11
+ def lcp_emit_engine_assets?
12
+ !LcpRuby.configuration.skip_asset_pipeline_check
13
+ end
14
+
3
15
  # Resolves a column or field label using the same fallback chain as
4
16
  # FieldDefinition#resolved_label, with one extra step for synthetic
5
17
  # fields (timestamps, FK columns, dotted association paths) that don't
@@ -200,11 +212,22 @@ module LcpRuby
200
212
  true
201
213
  end
202
214
 
203
- # MenuItem-shaped keys allowed in provider returns. `provider:`
204
- # and `panel_provider:` are stripped (chains are banned each
205
- # YAML position resolves to one source, never a chain).
215
+ # Internal/derived keys silently dropped from provider returns. `type`
216
+ # is always re-derived from the destination keys, so a provider that
217
+ # echoes it (e.g. from a stored to_kwargs snapshot) must not trip the
218
+ # forbidden-key check below — nor reach MenuItem.from_hash_at_depth,
219
+ # which rejects `type` to stay consistent with the JSON schema.
220
+ PROVIDER_STRIP_KEYS = %w[type].freeze
221
+
222
+ # MenuItem-shaped keys allowed in provider returns. `provider:` and
223
+ # `panel_provider:` are NOT listed (chains are banned — each YAML position
224
+ # resolves to one source, never a chain), so a provider return carrying
225
+ # them hits the forbidden-key branch and raises (record_error in prod) —
226
+ # they are rejected, not silently dropped (that is PROVIDER_STRIP_KEYS).
227
+ # MUST stay a subset of MenuItem::KNOWN_KEYS — every key here is fed
228
+ # straight into MenuItem.from_hash_at_depth, which rejects unknown keys.
206
229
  PROVIDER_RETURN_ALLOWED_KEYS = %w[
207
- type label label_key icon url method aria_label aria_label_key
230
+ label label_key icon url method aria_label aria_label_key
208
231
  badge children visible_when disable_when presenter alias action
209
232
  defaults render render_panel widget position options
210
233
  ].freeze
@@ -277,11 +300,12 @@ module LcpRuby
277
300
  end
278
301
 
279
302
  # Filters a provider-returned hash to the whitelisted MenuItem keys.
280
- # Drops `provider:` and `panel_provider:` (chains are banned).
281
- # Drops anything else with the canonical idiom raise in dev/test,
282
- # record_error in production.
303
+ # PROVIDER_STRIP_KEYS (e.g. `type`) are dropped silently at EVERY depth
304
+ # first (so a `children: [{type:…}]` echo doesn't trip reject_unknown_keys!
305
+ # during the nested build), then top-level keys outside
306
+ # PROVIDER_RETURN_ALLOWED_KEYS raise — record_error in production.
283
307
  def filter_provider_keys(hash, provider_name)
284
- stringified = hash.transform_keys(&:to_s)
308
+ stringified = deep_strip_provider_keys(hash).transform_keys(&:to_s)
285
309
  sanitized = {}
286
310
 
287
311
  stringified.each do |k, v|
@@ -302,6 +326,23 @@ module LcpRuby
302
326
  sanitized
303
327
  end
304
328
 
329
+ # Removes PROVIDER_STRIP_KEYS (internal/derived keys a provider may echo,
330
+ # e.g. `type`) from a provider-returned item AND recursively from its
331
+ # nested `children:`. These keys are re-derived by MenuItem, so stripping
332
+ # them at every depth keeps reject_unknown_keys! from raising on an echoed
333
+ # `type:` in a nested child. Key type (String/Symbol) is preserved.
334
+ def deep_strip_provider_keys(hash)
335
+ hash.each_with_object({}) do |(k, v), out|
336
+ next if PROVIDER_STRIP_KEYS.include?(k.to_s)
337
+
338
+ out[k] = if k.to_s == "children" && v.is_a?(Array)
339
+ v.map { |child| child.is_a?(Hash) ? deep_strip_provider_keys(child) : child }
340
+ else
341
+ v
342
+ end
343
+ end
344
+ end
345
+
305
346
  # Builds the `request_context:` hash passed to provider classes.
306
347
  # Provider may run outside a controller-driven request (cache
307
348
  # warm-up, mailer, background job that builds an HTML preview);
@@ -4,6 +4,21 @@ module LcpRuby
4
4
  reparentable: false, search_active: false)
5
5
  return "".html_safe if roots.blank?
6
6
 
7
+ # Pre-compute terminal field_def + owning model_name per column once,
8
+ # then pass through recursive calls. Avoids O(rows × cols × fields)
9
+ # linear scans on deep trees (the inline lookup in render_tree_row used
10
+ # to do this per (row × column) without memoization).
11
+ @_tree_col_field_defs ||= {}
12
+ @_tree_col_model_names ||= {}
13
+ columns.each do |col|
14
+ path = col["field"]
15
+ next unless path
16
+ next if @_tree_col_field_defs.key?(path)
17
+ fd, mn = LcpRuby::Metadata::FieldPath.terminal(current_model_definition, path, through_collections: true)
18
+ @_tree_col_field_defs[path] = fd
19
+ @_tree_col_model_names[path] = mn || current_model_name
20
+ end
21
+
7
22
  roots.each_with_index.map { |record, idx|
8
23
  render_tree_row(record, children_map, columns, depth: depth,
9
24
  default_expanded: default_expanded, match_ids: match_ids,
@@ -83,17 +98,23 @@ module LcpRuby
83
98
  end
84
99
 
85
100
  value = @field_resolver.resolve(record, col["field"], fk_map: @fk_map)
101
+ fd = @_tree_col_field_defs[col["field"]]
102
+ fd_model = @_tree_col_model_names[col["field"]] || current_model_name
86
103
 
87
104
  rendered_value = if col["partial"]
88
105
  render(partial: col["partial"], locals: { value: value, record: record, options: col["options"] || {} })
89
106
  elsif col["renderer"]
90
- render_display_value(value, col["renderer"], col["options"], nil, record: record).to_s
107
+ render_display_value(value, col["renderer"], col["options"], fd, record: record, model_name: fd_model).to_s
91
108
  elsif value.is_a?(Array)
92
- render_display_value(value, "collection", {}).to_s
109
+ # Pass fd + model so enum elements humanize (issue #18) — the
110
+ # collection renderer itself only does item.to_s.
111
+ render_display_value(value, "collection", {}, fd, record: record, model_name: fd_model).to_s
93
112
  elsif value.is_a?(ActiveRecord::Base)
94
113
  empty_value_placeholder(display_association_value(value), current_presenter).to_s
95
114
  else
96
- empty_value_placeholder(value, current_presenter).to_s
115
+ # Resolver returns raw enum keys (issue #18); humanize via field's
116
+ # owning model namespace.
117
+ empty_value_placeholder(format_enum_display(value, fd, fd_model), current_presenter).to_s
97
118
  end
98
119
  cell_content << wrap_link_through(value, rendered_value, record, col)
99
120
 
@@ -9,6 +9,16 @@ module LcpRuby
9
9
  class User < ActiveRecord::Base
10
10
  self.table_name = "lcp_ruby_users"
11
11
 
12
+ # Declare the AR :json attribute type for the JSON columns explicitly.
13
+ # PostgreSQL (jsonb) and SQLite reflect these correctly, but MariaDB reports
14
+ # JSON columns as `longtext`, so without this AR serializes a Hash via
15
+ # Hash#to_s (`{"k"=>"v"}`) — invalid JSON that trips MariaDB's implicit
16
+ # json_valid() CHECK on insert, and returns a raw String on read. The proc
17
+ # defaults mirror the create_lcp_ruby_users migration and hand each new
18
+ # record its own object.
19
+ attribute :lcp_role, :json, default: -> { [ "viewer" ] }
20
+ attribute :profile_data, :json, default: -> { {} }
21
+
12
22
  # Devise modules — lockable and timeoutable are always declared but
13
23
  # their behavior depends on configuration (lock_after_attempts, session_timeout).
14
24
  devise :database_authenticatable, :registerable, :recoverable,